From b989ca639744573e00dd9c31acae7797aa7a141c Mon Sep 17 00:00:00 2001 From: micahmills Date: Tue, 9 Sep 2025 11:57:36 +0300 Subject: [PATCH 01/22] feat: Agent language settings (#11222) # Pull Request Template ## Description This Pull Request will provide a language selector in the Profile Settings for each user, and allows them to change the UI language per agent, defaulting back to the account locale. Fixes # #678 This does PR addresses the Dashboard view but does not change the language of the agents emails ## Type of change Please delete options that are not relevant. - [X ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? 1. Go to an Agents Profile settings page 2. Select a language from the Language drop down 3. the UI will update to the new i18n locale 4. navigate through the UI to make sure the appropriate language is being used 5. Refresh the page to test that the locale persists 270 - [X] My code follows the style guidelines of this project - [X] I have performed a self-review of my code - [X] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [X] My changes generate no new warnings - [X] I have added tests that prove my fix is effective or that my feature works - [X] New and existing unit tests pass locally with my changes - [X] Any dependent changes have been merged and published in downstream modules Checklist:.724.2708 --------- Co-authored-by: Sojan Jose Co-authored-by: Pranav Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin Co-authored-by: Muhsin Keloth --- app/controllers/concerns/switch_locale.rb | 21 +++- app/javascript/dashboard/App.vue | 11 +- .../composables/spec/useFontSize.spec.js | 26 +++-- .../dashboard/composables/useFontSize.js | 4 +- .../dashboard/i18n/locale/en/settings.json | 7 ++ .../dashboard/settings/account/Index.vue | 23 ++-- .../dashboard/settings/profile/Index.vue | 8 ++ .../settings/profile/UserLanguageSelect.vue | 103 ++++++++++++++++++ .../dashboard/store/modules/accounts.js | 14 ++- .../modules/specs/account/getters.spec.js | 79 ++++++++++---- spec/models/concerns/switch_locale_spec.rb | 20 ++++ 11 files changed, 261 insertions(+), 55 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/profile/UserLanguageSelect.vue diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb index a8ea8ae05..1221d7155 100644 --- a/app/controllers/concerns/switch_locale.rb +++ b/app/controllers/concerns/switch_locale.rb @@ -4,17 +4,28 @@ module SwitchLocale private def switch_locale(&) - # priority is for locale set in query string (mostly for widget/from js sdk) + # Priority is for locale set in query string (mostly for widget/from js sdk) locale ||= params[:locale] + # Use the user's locale if available + locale ||= locale_from_user + + # Use the locale from a custom domain if applicable locale ||= locale_from_custom_domain + # if locale is not set in account, let's use DEFAULT_LOCALE env variable locale ||= ENV.fetch('DEFAULT_LOCALE', nil) + set_locale(locale, &) end def switch_locale_using_account_locale(&) - locale = locale_from_account(@current_account) + # Get the locale from the user first + locale = locale_from_user + + # Fallback to the account's locale if the user's locale is not set + locale ||= locale_from_account(@current_account) + set_locale(locale, &) end @@ -32,6 +43,12 @@ module SwitchLocale @portal.default_locale end + def locale_from_user + return unless @user + + @user.ui_settings&.dig('locale') + end + def set_locale(locale, &) safe_locale = validate_and_get_locale(locale) # Ensure locale won't bleed into other requests diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 8da7e7476..0fcb8c9fe 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -19,6 +19,7 @@ import { verifyServiceWorkerExistence, } from './helper/pushHelper'; import ReconnectService from 'dashboard/helper/ReconnectService'; +import { useUISettings } from 'dashboard/composables/useUISettings'; export default { name: 'App', @@ -38,12 +39,14 @@ export default { const { accountId } = useAccount(); // Use the font size composable (it automatically sets up the watcher) const { currentFontSize } = useFontSize(); + const { uiSettings } = useUISettings(); return { router, store, currentAccountId: accountId, currentFontSize, + uiSettings, }; }, data() { @@ -88,7 +91,10 @@ export default { mounted() { this.initializeColorTheme(); this.listenToThemeChanges(); - this.setLocale(window.chatwootConfig.selectedLocale); + // If user locale is set, use it; otherwise use account locale + this.setLocale( + this.uiSettings?.locale || window.chatwootConfig.selectedLocale + ); }, unmounted() { if (this.reconnectService) { @@ -114,7 +120,8 @@ export default { const { locale, latest_chatwoot_version: latestChatwootVersion } = this.getAccount(this.currentAccountId); const { pubsub_token: pubsubToken } = this.currentUser || {}; - this.setLocale(locale); + // If user locale is set, use it; otherwise use account locale + this.setLocale(this.uiSettings?.locale || locale); this.latestChatwootVersion = latestChatwootVersion; vueActionCable.init(this.store, pubsubToken); this.reconnectService = new ReconnectService(this.store, this.router); diff --git a/app/javascript/dashboard/composables/spec/useFontSize.spec.js b/app/javascript/dashboard/composables/spec/useFontSize.spec.js index 52d22478f..28b927610 100644 --- a/app/javascript/dashboard/composables/spec/useFontSize.spec.js +++ b/app/javascript/dashboard/composables/spec/useFontSize.spec.js @@ -43,18 +43,22 @@ describe('useFontSize', () => { it('returns fontSizeOptions with correct structure', () => { const { fontSizeOptions } = useFontSize(); - expect(fontSizeOptions).toHaveLength(5); - expect(fontSizeOptions[0]).toHaveProperty('value'); - expect(fontSizeOptions[0]).toHaveProperty('label'); + expect(fontSizeOptions.value).toHaveLength(5); + expect(fontSizeOptions.value[0]).toHaveProperty('value'); + expect(fontSizeOptions.value[0]).toHaveProperty('label'); // Check specific options - expect(fontSizeOptions.find(option => option.value === '16px')).toEqual({ + expect( + fontSizeOptions.value.find(option => option.value === '16px') + ).toEqual({ value: '16px', label: 'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT', }); - expect(fontSizeOptions.find(option => option.value === '14px')).toEqual({ + expect( + fontSizeOptions.value.find(option => option.value === '14px') + ).toEqual({ value: '14px', label: 'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER', @@ -143,12 +147,12 @@ describe('useFontSize', () => { const { fontSizeOptions } = useFontSize(); // Check that translation is applied - expect(fontSizeOptions.find(option => option.value === '14px').label).toBe( - 'Smaller' - ); - expect(fontSizeOptions.find(option => option.value === '16px').label).toBe( - 'Default' - ); + expect( + fontSizeOptions.value.find(option => option.value === '14px').label + ).toBe('Smaller'); + expect( + fontSizeOptions.value.find(option => option.value === '16px').label + ).toBe('Default'); // Verify translation function was called with correct keys expect(mockTranslate).toHaveBeenCalledWith( diff --git a/app/javascript/dashboard/composables/useFontSize.js b/app/javascript/dashboard/composables/useFontSize.js index d7177a5fb..92d6f9e72 100644 --- a/app/javascript/dashboard/composables/useFontSize.js +++ b/app/javascript/dashboard/composables/useFontSize.js @@ -77,8 +77,8 @@ export const useFontSize = () => { * Font size options for select dropdown * @type {Array<{value: string, label: string}>} */ - const fontSizeOptions = FONT_SIZE_NAMES.map(name => - createFontSizeOption(t, name) + const fontSizeOptions = computed(() => + FONT_SIZE_NAMES.map(name => createFontSizeOption(t, name)) ); /** diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 5b2197a03..2e24bace4 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -51,6 +51,13 @@ "LARGER": "Larger", "EXTRA_LARGE": "Extra Large" } + }, + "LANGUAGE": { + "TITLE": "Preferred Language", + "NOTE": "Choose the language you want to use.", + "UPDATE_SUCCESS": "Your Language settings have been updated successfully", + "UPDATE_ERROR": "There is an error while updating the language settings, please try again", + "USE_ACCOUNT_DEFAULT": "Use account default" } }, "MESSAGE_SIGNATURE_SECTION": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue index 69342858d..c6e9a1ebb 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue @@ -7,7 +7,6 @@ import { useUISettings } from 'dashboard/composables/useUISettings'; import { useConfig } from 'dashboard/composables/useConfig'; import { useAccount } from 'dashboard/composables/useAccount'; import { FEATURE_FLAGS } from '../../../../featureFlags'; -import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages'; import WithLabel from 'v3/components/Form/WithLabel.vue'; import NextInput from 'next/input/Input.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; @@ -33,12 +32,12 @@ export default { NextInput, }, setup() { - const { updateUISettings } = useUISettings(); + const { updateUISettings, uiSettings } = useUISettings(); const { enabledLanguages } = useConfig(); const { accountId } = useAccount(); const v$ = useVuelidate(); - return { updateUISettings, v$, enabledLanguages, accountId }; + return { updateUISettings, uiSettings, v$, enabledLanguages, accountId }; }, data() { return { @@ -112,7 +111,7 @@ export default { const { name, locale, id, domain, support_email, features } = this.getAccount(this.accountId); - this.$root.$i18n.locale = locale; + this.$root.$i18n.locale = this.uiSettings?.locale || locale; this.name = name; this.locale = locale; this.id = id; @@ -137,21 +136,19 @@ export default { domain: this.domain, support_email: this.supportEmail, }); - this.$root.$i18n.locale = this.locale; + // If user locale is set, update the locale with user locale + if (this.uiSettings?.locale) { + this.$root.$i18n.locale = this.uiSettings?.locale; + } else { + // If user locale is not set, update the locale with account locale + this.$root.$i18n.locale = this.locale; + } this.getAccount(this.id).locale = this.locale; - this.updateDirectionView(this.locale); useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS')); } catch (error) { useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR')); } }, - - updateDirectionView(locale) { - const isRTLSupported = getLanguageDirection(locale); - this.updateUISettings({ - rtl_view: isRTLSupported, - }); - }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue index bbde7a1c0..ce0a480bb 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue @@ -11,6 +11,7 @@ import UserProfilePicture from './UserProfilePicture.vue'; import UserBasicDetails from './UserBasicDetails.vue'; import MessageSignature from './MessageSignature.vue'; import FontSize from './FontSize.vue'; +import UserLanguageSelect from './UserLanguageSelect.vue'; import HotKeyCard from './HotKeyCard.vue'; import ChangePassword from './ChangePassword.vue'; import NotificationPreferences from './NotificationPreferences.vue'; @@ -28,6 +29,7 @@ export default { MessageSignature, FormSection, FontSize, + UserLanguageSelect, UserProfilePicture, Policy, UserBasicDetails, @@ -230,6 +232,12 @@ export default { " @change="updateFontSize" /> + +import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useAlert } from 'dashboard/composables'; +import { useConfig } from 'dashboard/composables/useConfig'; +import { useAccount } from 'dashboard/composables/useAccount'; +import { useUISettings } from 'dashboard/composables/useUISettings'; + +import FormSelect from 'v3/components/Form/Select.vue'; + +defineProps({ + label: { type: String, default: '' }, + description: { type: String, default: '' }, +}); + +const { t, locale } = useI18n(); +const { updateUISettings, uiSettings } = useUISettings(); +const { enabledLanguages } = useConfig(); +const { currentAccount } = useAccount(); + +const currentLanguage = computed(() => uiSettings.value?.locale ?? ''); + +const languageOptions = computed(() => [ + { + name: t( + 'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.USE_ACCOUNT_DEFAULT' + ), + iso_639_1_code: '', + }, + ...(enabledLanguages ?? []), +]); + +const updateLanguage = async languageCode => { + try { + if (!languageCode) { + // Clear preference to use account default + await updateUISettings({ locale: null }); + locale.value = currentAccount.value.locale; + useAlert( + t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_SUCCESS') + ); + return; + } + + const valid = (enabledLanguages || []).some( + l => l.iso_639_1_code === languageCode + ); + if (!valid) { + throw new Error(`Invalid language code: ${languageCode}`); + } + + await updateUISettings({ locale: languageCode }); + // Apply immediately if the user explicitly chose a preference + locale.value = languageCode; + + useAlert( + t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_SUCCESS') + ); + } catch (error) { + useAlert( + t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_ERROR') + ); + throw error; + } +}; + +const selectedValue = computed({ + get: () => currentLanguage.value, + set: value => { + updateLanguage(value); + }, +}); + + + diff --git a/app/javascript/dashboard/store/modules/accounts.js b/app/javascript/dashboard/store/modules/accounts.js index 0d5fdc748..1eef59cfe 100644 --- a/app/javascript/dashboard/store/modules/accounts.js +++ b/app/javascript/dashboard/store/modules/accounts.js @@ -28,12 +28,16 @@ export const getters = { getUIFlags($state) { return $state.uiFlags; }, - isRTL: ($state, _, rootState) => { - const accountId = rootState.route?.params?.accountId; - if (!accountId) return false; + isRTL: ($state, _getters, rootState, rootGetters) => { + const accountId = Number(rootState.route?.params?.accountId); + const userLocale = rootGetters?.getUISettings?.locale; + const accountLocale = + accountId && findRecordById($state, accountId)?.locale; - const { locale } = findRecordById($state, Number(accountId)); - return locale ? getLanguageDirection(locale) : false; + // Prefer user locale; fallback to account locale + const effectiveLocale = userLocale ?? accountLocale; + + return effectiveLocale ? getLanguageDirection(effectiveLocale) : false; }, isTrialAccount: $state => id => { const account = findRecordById($state, id); diff --git a/app/javascript/dashboard/store/modules/specs/account/getters.spec.js b/app/javascript/dashboard/store/modules/specs/account/getters.spec.js index 77cc9b357..f354faca2 100644 --- a/app/javascript/dashboard/store/modules/specs/account/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/account/getters.spec.js @@ -49,35 +49,74 @@ describe('#getters', () => { }); describe('isRTL', () => { - it('returns false when accountId is not present', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false when accountId is not present and userLocale is not set', () => { + const state = { records: [accountData] }; const rootState = { route: { params: {} } }; - expect(getters.isRTL({}, null, rootState)).toBe(false); + const rootGetters = {}; + + expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false); }); - it('returns true for RTL language', () => { - const state = { - records: [{ id: 1, locale: 'ar' }], - }; - const rootState = { route: { params: { accountId: '1' } } }; - vi.spyOn(languageHelpers, 'getLanguageDirection').mockReturnValue(true); - expect(getters.isRTL(state, null, rootState)).toBe(true); + it('uses userLocale when present (no accountId)', () => { + const state = { records: [accountData] }; + const rootState = { route: { params: {} } }; + const rootGetters = { getUISettings: { locale: 'ar' } }; + const spy = vi + .spyOn(languageHelpers, 'getLanguageDirection') + .mockReturnValue(true); + + expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true); + expect(spy).toHaveBeenCalledWith('ar'); }); - it('returns false for LTR language', () => { - const state = { - records: [{ id: 1, locale: 'en' }], - }; + it('prefers userLocale over account locale when both are present', () => { + const state = { records: [{ id: 1, locale: 'en' }] }; const rootState = { route: { params: { accountId: '1' } } }; - vi.spyOn(languageHelpers, 'getLanguageDirection').mockReturnValue(false); - expect(getters.isRTL(state, null, rootState)).toBe(false); + const rootGetters = { getUISettings: { locale: 'ar' } }; + const spy = vi + .spyOn(languageHelpers, 'getLanguageDirection') + .mockReturnValue(true); + + expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true); + expect(spy).toHaveBeenCalledWith('ar'); }); - it('returns false when account is not found', () => { - const state = { - records: [], - }; + it('falls back to account locale when userLocale is not provided', () => { + const state = { records: [{ id: 1, locale: 'ar' }] }; const rootState = { route: { params: { accountId: '1' } } }; - expect(getters.isRTL(state, null, rootState)).toBe(false); + const rootGetters = {}; + const spy = vi + .spyOn(languageHelpers, 'getLanguageDirection') + .mockReturnValue(true); + + expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true); + expect(spy).toHaveBeenCalledWith('ar'); + }); + + it('returns false for LTR language when userLocale is provided', () => { + const state = { records: [{ id: 1, locale: 'en' }] }; + const rootState = { route: { params: { accountId: '1' } } }; + const rootGetters = { getUISettings: { locale: 'en' } }; + const spy = vi + .spyOn(languageHelpers, 'getLanguageDirection') + .mockReturnValue(false); + + expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false); + expect(spy).toHaveBeenCalledWith('en'); + }); + + it('returns false when accountId present but user locale is null', () => { + const state = { records: [{ id: 1, locale: 'en' }] }; + const rootState = { route: { params: { accountId: '1' } } }; + const rootGetters = { getUISettings: { locale: null } }; + const spy = vi.spyOn(languageHelpers, 'getLanguageDirection'); + + expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false); + expect(spy).toHaveBeenCalledWith('en'); }); }); }); diff --git a/spec/models/concerns/switch_locale_spec.rb b/spec/models/concerns/switch_locale_spec.rb index 98628b92c..421f254a0 100644 --- a/spec/models/concerns/switch_locale_spec.rb +++ b/spec/models/concerns/switch_locale_spec.rb @@ -29,6 +29,26 @@ RSpec.describe 'SwitchLocale Concern', type: :controller do end end + context 'when user has a locale set in ui_settings' do + let(:user) { create(:user, ui_settings: { 'locale' => 'es' }) } + + before { controller.instance_variable_set(:@user, user) } + + it 'returns the user locale' do + expect(controller.send(:locale_from_user)).to eq('es') + end + end + + context 'when user does not have a locale set' do + let(:user) { create(:user, ui_settings: {}) } + + before { controller.instance_variable_set(:@user, user) } + + it 'returns nil' do + expect(controller.send(:locale_from_user)).to be_nil + end + end + context 'when request is from custom domain' do before { request.host = portal.custom_domain } From 0e1c3c559687ce23fbcecc9b7994197d6aa3bd4c Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 9 Sep 2025 16:51:51 +0530 Subject: [PATCH 02/22] chore: Remove duplicate webhook section in twilio sms finish page (#12398) **Before** CleanShot 2025-09-09 at 15 34 24 **After** CleanShot 2025-09-09 at 15 34 00 --- .../routes/dashboard/settings/inbox/FinishSetup.vue | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue index 13985784d..b19416137 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue @@ -34,6 +34,7 @@ const { isAWhatsAppChannel, isAFacebookInbox, isATelegramChannel, + isATwilioWhatsAppChannel, } = useInbox(route.params.inbox_id); const hasDuplicateInstagramInbox = computed(() => { @@ -168,7 +169,7 @@ onMounted(() => { 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 09/22] 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 10/22] 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 11/22] 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 12/22] 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 13/22] 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 14/22] 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 15/22] 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 16/22] 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 17/22] 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 18/22] 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 19/22] 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 20/22] 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 21/22] 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 => {