Merge branch 'develop' into rethinking-copilot
This commit is contained in:
@@ -12,10 +12,6 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
|
||||
Current.account
|
||||
).perform
|
||||
|
||||
# Only allow conversations from inboxes the user has access to
|
||||
inbox_ids = Current.user.assigned_inboxes.pluck(:id)
|
||||
conversations = conversations.where(inbox_id: inbox_ids)
|
||||
|
||||
@conversations = conversations.order(last_activity_at: :desc).limit(20)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -94,7 +94,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
|
||||
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, :state_id,
|
||||
label_ids: [])
|
||||
end
|
||||
|
||||
def fetch_hook
|
||||
|
||||
@@ -88,7 +88,10 @@ class ConversationFinder
|
||||
|
||||
def find_conversation_by_inbox
|
||||
@conversations = current_account.conversations
|
||||
@conversations = @conversations.where(inbox_id: @inbox_ids) unless params[:inbox_id].blank? && @is_admin
|
||||
|
||||
return unless params[:inbox_id]
|
||||
|
||||
@conversations = @conversations.where(inbox_id: @inbox_ids)
|
||||
end
|
||||
|
||||
def find_all_conversations
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
|
||||
import {
|
||||
DropdownContainer,
|
||||
@@ -20,6 +21,8 @@ const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline');
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
|
||||
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
|
||||
const statusList = computed(() => {
|
||||
return [
|
||||
@@ -46,6 +49,10 @@ const activeStatus = computed(() => {
|
||||
});
|
||||
|
||||
function changeAvailabilityStatus(availability) {
|
||||
if (isImpersonating.value) {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
store.dispatch('updateAvailability', {
|
||||
availability,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
|
||||
@@ -20,6 +21,10 @@ export default {
|
||||
AvailabilityStatusBadge,
|
||||
NextButton,
|
||||
},
|
||||
setup() {
|
||||
const { isImpersonating } = useImpersonation();
|
||||
return { isImpersonating };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isStatusMenuOpened: false,
|
||||
@@ -73,6 +78,13 @@ export default {
|
||||
});
|
||||
},
|
||||
changeAvailabilityStatus(availability) {
|
||||
if (this.isImpersonating) {
|
||||
useAlert(
|
||||
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isUpdating) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useImpersonation } from '../useImpersonation';
|
||||
|
||||
vi.mock('shared/helpers/sessionStorage', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
|
||||
describe('useImpersonation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return true if impersonation flag is set in session storage', () => {
|
||||
SessionStorage.get.mockReturnValue(true);
|
||||
const { isImpersonating } = useImpersonation();
|
||||
expect(isImpersonating.value).toBe(true);
|
||||
expect(SessionStorage.get).toHaveBeenCalledWith(
|
||||
SESSION_STORAGE_KEYS.IMPERSONATION_USER
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false if impersonation flag is not set in session storage', () => {
|
||||
SessionStorage.get.mockReturnValue(false);
|
||||
const { isImpersonating } = useImpersonation();
|
||||
expect(isImpersonating.value).toBe(false);
|
||||
expect(SessionStorage.get).toHaveBeenCalledWith(
|
||||
SESSION_STORAGE_KEYS.IMPERSONATION_USER
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { computed } from 'vue';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
|
||||
export function useImpersonation() {
|
||||
const isImpersonating = computed(() => {
|
||||
return SessionStorage.get(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
|
||||
});
|
||||
return { isImpersonating };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const SESSION_STORAGE_KEYS = {
|
||||
IMPERSONATION_USER: 'impersonationUser',
|
||||
};
|
||||
@@ -59,6 +59,11 @@
|
||||
"ERROR_MESSAGE": "Could not update bot. Please try again."
|
||||
}
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"DESCRIPTION": "Copy the access token and save it securely",
|
||||
"COPY_SUCCESSFUL": "Access token copied to clipboard"
|
||||
},
|
||||
"FORM": {
|
||||
"AVATAR": {
|
||||
"LABEL": "Bot avatar"
|
||||
|
||||
@@ -185,7 +185,8 @@
|
||||
"OFFLINE": "Offline"
|
||||
},
|
||||
"SET_AVAILABILITY_SUCCESS": "Availability has been set successfully",
|
||||
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again"
|
||||
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again",
|
||||
"IMPERSONATING_ERROR": "Cannot change availability while impersonating a user"
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "Your email address",
|
||||
|
||||
+131
-45
@@ -5,12 +5,15 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { required, helpers, url } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
@@ -42,6 +45,9 @@ const formState = reactive({
|
||||
botAvatarUrl: '',
|
||||
});
|
||||
|
||||
const [showAccessToken, toggleAccessToken] = useToggle();
|
||||
const accessToken = ref('');
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
botName: {
|
||||
@@ -70,11 +76,22 @@ const isLoading = computed(() =>
|
||||
: uiFlags.value.isUpdating
|
||||
);
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
props.type === MODAL_TYPES.CREATE
|
||||
const dialogTitle = computed(() => {
|
||||
if (showAccessToken.value) {
|
||||
return t('AGENT_BOTS.ACCESS_TOKEN.TITLE');
|
||||
}
|
||||
|
||||
return props.type === MODAL_TYPES.CREATE
|
||||
? t('AGENT_BOTS.ADD.TITLE')
|
||||
: t('AGENT_BOTS.EDIT.TITLE')
|
||||
);
|
||||
: t('AGENT_BOTS.EDIT.TITLE');
|
||||
});
|
||||
|
||||
const dialogDescription = computed(() => {
|
||||
if (showAccessToken.value) {
|
||||
return t('AGENT_BOTS.ACCESS_TOKEN.DESCRIPTION');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const confirmButtonLabel = computed(() =>
|
||||
props.type === MODAL_TYPES.CREATE
|
||||
@@ -90,6 +107,13 @@ const botUrlError = computed(() =>
|
||||
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
|
||||
);
|
||||
|
||||
const showAccessTokenInput = computed(
|
||||
() =>
|
||||
showAccessToken.value ||
|
||||
props.type === MODAL_TYPES.EDIT ||
|
||||
accessToken.value
|
||||
);
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formState, {
|
||||
botName: '',
|
||||
@@ -128,6 +152,7 @@ const handleAvatarDelete = async () => {
|
||||
const handleSubmit = async () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
if (showAccessToken.value) return;
|
||||
|
||||
const botData = {
|
||||
name: formState.botName,
|
||||
@@ -144,7 +169,7 @@ const handleSubmit = async () => {
|
||||
? botData
|
||||
: { id: props.selectedBot.id, data: botData };
|
||||
|
||||
await store.dispatch(
|
||||
const response = await store.dispatch(
|
||||
`agentBots/${isCreate ? 'create' : 'update'}`,
|
||||
actionPayload
|
||||
);
|
||||
@@ -154,7 +179,21 @@ const handleSubmit = async () => {
|
||||
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
|
||||
useAlert(alertKey);
|
||||
|
||||
dialogRef.value.close();
|
||||
// Show access token after creation
|
||||
if (isCreate) {
|
||||
const { access_token: responseAccessToken, id } = response || {};
|
||||
|
||||
if (id && responseAccessToken) {
|
||||
accessToken.value = responseAccessToken;
|
||||
toggleAccessToken(true);
|
||||
} else {
|
||||
accessToken.value = '';
|
||||
dialogRef.value.close();
|
||||
}
|
||||
} else {
|
||||
dialogRef.value.close();
|
||||
}
|
||||
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
const errorKey = isCreate
|
||||
@@ -166,17 +205,43 @@ const handleSubmit = async () => {
|
||||
|
||||
const initializeForm = () => {
|
||||
if (props.selectedBot && Object.keys(props.selectedBot).length) {
|
||||
const { name, description, outgoing_url, thumbnail, bot_config } =
|
||||
props.selectedBot;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
outgoing_url: botUrl,
|
||||
thumbnail,
|
||||
bot_config: botConfig,
|
||||
access_token: botAccessToken,
|
||||
} = props.selectedBot;
|
||||
formState.botName = name || '';
|
||||
formState.botDescription = description || '';
|
||||
formState.botUrl = outgoing_url || bot_config?.webhook_url || '';
|
||||
formState.botUrl = botUrl || botConfig?.webhook_url || '';
|
||||
formState.botAvatarUrl = thumbnail || '';
|
||||
|
||||
if (botAccessToken && props.type === MODAL_TYPES.EDIT) {
|
||||
accessToken.value = botAccessToken;
|
||||
}
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const onCopyToken = async value => {
|
||||
await copyTextToClipboard(value);
|
||||
useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (!showAccessToken.value) v$.value?.$reset();
|
||||
accessToken.value = '';
|
||||
toggleAccessToken(false);
|
||||
};
|
||||
|
||||
const onClickClose = () => {
|
||||
closeModal();
|
||||
dialogRef.value.close();
|
||||
};
|
||||
|
||||
watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true });
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
@@ -187,48 +252,68 @@ defineExpose({ dialogRef });
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="dialogTitle"
|
||||
:description="dialogDescription"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="v$.$reset()"
|
||||
@close="closeModal"
|
||||
>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<div class="mb-2 flex flex-col items-start">
|
||||
<span class="mb-2 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
|
||||
</span>
|
||||
<Avatar
|
||||
:src="formState.botAvatarUrl"
|
||||
:name="formState.botName"
|
||||
:size="68"
|
||||
allow-upload
|
||||
@upload="handleImageUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
<div
|
||||
v-if="!showAccessToken || type === MODAL_TYPES.EDIT"
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<div class="mb-2 flex flex-col items-start">
|
||||
<span class="mb-2 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
|
||||
</span>
|
||||
<Avatar
|
||||
:src="formState.botAvatarUrl"
|
||||
:name="formState.botName"
|
||||
:size="68"
|
||||
allow-upload
|
||||
icon-name="i-lucide-bot-message-square"
|
||||
@upload="handleImageUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="bot-name"
|
||||
v-model="formState.botName"
|
||||
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
|
||||
:message="botNameError"
|
||||
:message-type="botNameError ? 'error' : 'info'"
|
||||
@blur="v$.botName.$touch()"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
id="bot-description"
|
||||
v-model="formState.botDescription"
|
||||
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="bot-url"
|
||||
v-model="formState.botUrl"
|
||||
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
|
||||
:message="botUrlError"
|
||||
:message-type="botUrlError ? 'error' : 'info'"
|
||||
@blur="v$.botUrl.$touch()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="formState.botName"
|
||||
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
|
||||
:message="botNameError"
|
||||
:message-type="botNameError ? 'error' : 'info'"
|
||||
@blur="v$.botName.$touch()"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="formState.botDescription"
|
||||
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="formState.botUrl"
|
||||
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
|
||||
:message="botUrlError"
|
||||
:message-type="botUrlError ? 'error' : 'info'"
|
||||
@blur="v$.botUrl.$touch()"
|
||||
/>
|
||||
<div v-if="showAccessTokenInput" class="flex flex-col gap-1">
|
||||
<label
|
||||
v-if="type === MODAL_TYPES.EDIT"
|
||||
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
|
||||
</label>
|
||||
<AccessToken :value="accessToken" @on-copy="onCopyToken" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
|
||||
<NextButton
|
||||
@@ -236,9 +321,10 @@ defineExpose({ dialogRef });
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('AGENT_BOTS.FORM.CANCEL')"
|
||||
@click="dialogRef.close()"
|
||||
@click="onClickClose()"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="!showAccessToken"
|
||||
type="submit"
|
||||
data-testid="label-submit"
|
||||
:label="confirmButtonLabel"
|
||||
|
||||
@@ -39,6 +39,7 @@ const onClick = () => {
|
||||
<template #masked>
|
||||
<button
|
||||
class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5"
|
||||
type="button"
|
||||
@click="toggleMasked"
|
||||
>
|
||||
<fluent-icon :icon="maskIcon" :size="16" />
|
||||
@@ -46,7 +47,7 @@ const onClick = () => {
|
||||
</template>
|
||||
</woot-input>
|
||||
<FormButton
|
||||
type="submit"
|
||||
type="button"
|
||||
size="large"
|
||||
icon="text-copy"
|
||||
variant="outline"
|
||||
|
||||
@@ -2,6 +2,8 @@ import types from '../mutation-types';
|
||||
import authAPI from '../../api/auth';
|
||||
|
||||
import { setUser, clearCookiesOnLogout } from '../utils/api';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
|
||||
const initialState = {
|
||||
currentUser: {
|
||||
@@ -145,8 +147,15 @@ export const actions = {
|
||||
updateUISettings: async ({ commit }, params) => {
|
||||
try {
|
||||
commit(types.SET_CURRENT_USER_UI_SETTINGS, params);
|
||||
const response = await authAPI.updateUISettings(params);
|
||||
commit(types.SET_CURRENT_USER, response.data);
|
||||
|
||||
const isImpersonating = SessionStorage.get(
|
||||
SESSION_STORAGE_KEYS.IMPERSONATION_USER
|
||||
);
|
||||
|
||||
if (!isImpersonating) {
|
||||
const response = await authAPI.updateUISettings(params);
|
||||
commit(types.SET_CURRENT_USER, response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import differenceInDays from 'date-fns/differenceInDays';
|
||||
import Cookies from 'js-cookie';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import {
|
||||
ANALYTICS_IDENTITY,
|
||||
@@ -44,6 +46,10 @@ export const clearLocalStorageOnLogout = () => {
|
||||
LocalStorage.remove(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES);
|
||||
};
|
||||
|
||||
export const clearSessionStorageOnLogout = () => {
|
||||
SessionStorage.remove(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
|
||||
};
|
||||
|
||||
export const deleteIndexedDBOnLogout = async () => {
|
||||
let dbs = [];
|
||||
try {
|
||||
@@ -75,6 +81,7 @@ export const clearCookiesOnLogout = () => {
|
||||
emitter.emit(ANALYTICS_RESET);
|
||||
clearBrowserSessionCookies();
|
||||
clearLocalStorageOnLogout();
|
||||
clearSessionStorageOnLogout();
|
||||
const globalConfig = window.globalConfig || {};
|
||||
const logoutRedirectLink = globalConfig.LOGOUT_REDIRECT_LINK || '/';
|
||||
window.location = logoutRedirectLink;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export default {
|
||||
clearAll() {
|
||||
window.sessionStorage.clear();
|
||||
},
|
||||
|
||||
get(key) {
|
||||
try {
|
||||
const value = window.sessionStorage.getItem(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
} catch (error) {
|
||||
return window.sessionStorage.getItem(key);
|
||||
}
|
||||
},
|
||||
|
||||
set(key, value) {
|
||||
if (typeof value === 'object') {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(value));
|
||||
} else {
|
||||
window.sessionStorage.setItem(key, value);
|
||||
}
|
||||
},
|
||||
|
||||
remove(key) {
|
||||
window.sessionStorage.removeItem(key);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import SessionStorage from '../sessionStorage';
|
||||
|
||||
// Mocking sessionStorage
|
||||
const sessionStorageMock = (() => {
|
||||
let store = {};
|
||||
return {
|
||||
getItem: key => store[key] || null,
|
||||
setItem: (key, value) => {
|
||||
store[key] = String(value);
|
||||
},
|
||||
removeItem: key => delete store[key],
|
||||
clear: () => {
|
||||
store = {};
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: sessionStorageMock,
|
||||
});
|
||||
|
||||
describe('SessionStorage utility', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe('clearAll method', () => {
|
||||
it('should clear all items from sessionStorage', () => {
|
||||
sessionStorage.setItem('testKey1', 'testValue1');
|
||||
sessionStorage.setItem('testKey2', 'testValue2');
|
||||
|
||||
SessionStorage.clearAll();
|
||||
|
||||
expect(sessionStorage.getItem('testKey1')).toBeNull();
|
||||
expect(sessionStorage.getItem('testKey2')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('get method', () => {
|
||||
it('should retrieve and parse JSON values correctly', () => {
|
||||
const testObject = { a: 1, b: 'test' };
|
||||
sessionStorage.setItem('testKey', JSON.stringify(testObject));
|
||||
|
||||
expect(SessionStorage.get('testKey')).toEqual(testObject);
|
||||
});
|
||||
|
||||
it('should return null for non-existent keys', () => {
|
||||
expect(SessionStorage.get('nonExistentKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle non-JSON values by returning the raw value', () => {
|
||||
sessionStorage.setItem('testKey', 'plain string value');
|
||||
|
||||
expect(SessionStorage.get('testKey')).toBe('plain string value');
|
||||
});
|
||||
|
||||
it('should handle malformed JSON gracefully', () => {
|
||||
sessionStorage.setItem('testKey', '{malformed:json}');
|
||||
|
||||
expect(SessionStorage.get('testKey')).toBe('{malformed:json}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('set method', () => {
|
||||
it('should store object values as JSON strings', () => {
|
||||
const testObject = { a: 1, b: 'test' };
|
||||
SessionStorage.set('testKey', testObject);
|
||||
|
||||
expect(sessionStorage.getItem('testKey')).toBe(
|
||||
JSON.stringify(testObject)
|
||||
);
|
||||
});
|
||||
|
||||
it('should store primitive values directly', () => {
|
||||
SessionStorage.set('stringKey', 'test string');
|
||||
expect(sessionStorage.getItem('stringKey')).toBe('test string');
|
||||
|
||||
SessionStorage.set('numberKey', 42);
|
||||
expect(sessionStorage.getItem('numberKey')).toBe('42');
|
||||
|
||||
SessionStorage.set('booleanKey', true);
|
||||
expect(sessionStorage.getItem('booleanKey')).toBe('true');
|
||||
});
|
||||
|
||||
it('should handle null values', () => {
|
||||
SessionStorage.set('nullKey', null);
|
||||
|
||||
expect(sessionStorage.getItem('nullKey')).toBe('null');
|
||||
expect(SessionStorage.get('nullKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined values', () => {
|
||||
SessionStorage.set('undefinedKey', undefined);
|
||||
|
||||
expect(sessionStorage.getItem('undefinedKey')).toBe('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove method', () => {
|
||||
it('should remove an item from sessionStorage', () => {
|
||||
SessionStorage.set('testKey', 'testValue');
|
||||
expect(SessionStorage.get('testKey')).toBe('testValue');
|
||||
|
||||
SessionStorage.remove('testKey');
|
||||
|
||||
expect(SessionStorage.get('testKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('should do nothing when removing a non-existent key', () => {
|
||||
expect(() => {
|
||||
SessionStorage.remove('nonExistentKey');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration of methods', () => {
|
||||
it('should set, get, and remove values correctly', () => {
|
||||
SessionStorage.set('testKey', { value: 'test' });
|
||||
|
||||
expect(SessionStorage.get('testKey')).toEqual({ value: 'test' });
|
||||
|
||||
SessionStorage.remove('testKey');
|
||||
expect(SessionStorage.get('testKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('should correctly handle impersonation flag (common use case)', () => {
|
||||
SessionStorage.set('impersonationUser', true);
|
||||
|
||||
expect(SessionStorage.get('impersonationUser')).toBe(true);
|
||||
|
||||
expect(sessionStorage.getItem('impersonationUser')).toBe('true');
|
||||
|
||||
SessionStorage.remove('impersonationUser');
|
||||
expect(SessionStorage.get('impersonationUser')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import { parseBoolean } from '@chatwoot/utils';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { required, email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
// mixins
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
|
||||
@@ -21,6 +22,8 @@ const ERROR_MESSAGES = {
|
||||
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
|
||||
};
|
||||
|
||||
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FormInput,
|
||||
@@ -112,6 +115,14 @@ export default {
|
||||
this.loginApi.message = message;
|
||||
useAlert(this.loginApi.message);
|
||||
},
|
||||
handleImpersonation() {
|
||||
// Detects impersonation mode via URL and sets a session flag to prevent user settings changes during impersonation.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const impersonation = urlParams.get(IMPERSONATION_URL_SEARCH_KEY);
|
||||
if (impersonation) {
|
||||
SessionStorage.set(SESSION_STORAGE_KEYS.IMPERSONATION_USER, true);
|
||||
}
|
||||
},
|
||||
submitLogin() {
|
||||
this.loginApi.hasErrored = false;
|
||||
this.loginApi.showLoading = true;
|
||||
@@ -128,6 +139,7 @@ export default {
|
||||
|
||||
login(credentials)
|
||||
.then(() => {
|
||||
this.handleImpersonation();
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
.catch(response => {
|
||||
|
||||
@@ -20,6 +20,10 @@ module SsoAuthenticatable
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token}"
|
||||
end
|
||||
|
||||
def generate_sso_link_with_impersonation
|
||||
"#{generate_sso_link}&impersonation=true"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sso_token_key(token)
|
||||
|
||||
@@ -185,7 +185,13 @@ class Message < ApplicationRecord
|
||||
# move this to a presenter
|
||||
return self[:content] if !input_csat? || inbox.web_widget?
|
||||
|
||||
I18n.t('conversations.survey.response', link: "#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation.uuid}")
|
||||
survey_link = "#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation.uuid}"
|
||||
|
||||
if inbox.csat_config&.dig('message').present?
|
||||
"#{inbox.csat_config['message']} #{survey_link}"
|
||||
else
|
||||
I18n.t('conversations.survey.response', link: survey_link)
|
||||
end
|
||||
end
|
||||
|
||||
def email_notifiable_message?
|
||||
|
||||
@@ -28,16 +28,6 @@ class Conversations::FilterService < FilterService
|
||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
|
||||
)
|
||||
|
||||
account_user = @account.account_users.find_by(user_id: @user.id)
|
||||
is_administrator = account_user&.role == 'administrator'
|
||||
|
||||
# Ensure we only include conversations from inboxes the user has access to
|
||||
unless is_administrator
|
||||
inbox_ids = @user.inboxes.where(account_id: @account.id).pluck(:id)
|
||||
conversations = conversations.where(inbox_id: inbox_ids)
|
||||
end
|
||||
|
||||
# Apply permission-based filtering
|
||||
Conversations::PermissionFilterService.new(
|
||||
conversations,
|
||||
@user,
|
||||
|
||||
@@ -8,9 +8,23 @@ class Conversations::PermissionFilterService
|
||||
end
|
||||
|
||||
def perform
|
||||
# The base implementation simply returns all conversations
|
||||
# Enterprise edition extends this with permission-based filtering
|
||||
conversations
|
||||
return conversations if user_role == 'administrator'
|
||||
|
||||
accessible_conversations
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def accessible_conversations
|
||||
conversations.where(inbox: user.inboxes.where(account_id: account.id))
|
||||
end
|
||||
|
||||
def account_user
|
||||
AccountUser.find_by(account_id: account.id, user_id: user.id)
|
||||
end
|
||||
|
||||
def user_role
|
||||
account_user&.role
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<hr/>
|
||||
<div class="form-actions">
|
||||
<p class="text-color-red">Caution: Any actions executed after impersonate will appear as if performed by the impersonated user - [<%= page.resource.name %> ]</p>
|
||||
<a class='button' target='_blank' href='<%= page.resource.generate_sso_link %>'>Impersonate user </a>
|
||||
<a class='button' target='_blank' href='<%= page.resource.generate_sso_link_with_impersonation %>'>Impersonate user </a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.1.0'
|
||||
version: '4.2.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -9,6 +9,8 @@ class Captain::ToolRegistryService
|
||||
|
||||
def register_tool(tool_class)
|
||||
tool = tool_class.new(@assistant)
|
||||
return unless tool.active?
|
||||
|
||||
@tools[tool.name] = tool
|
||||
@registered_tools << tool.to_registry_format
|
||||
end
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
class Captain::Tools::BaseService
|
||||
attr_accessor :assistant
|
||||
|
||||
def initialize(assistant)
|
||||
def initialize(assistant, user: nil)
|
||||
@assistant = assistant
|
||||
@user = user
|
||||
end
|
||||
|
||||
def name
|
||||
@@ -31,4 +32,8 @@ class Captain::Tools::BaseService
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def active?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::BaseService
|
||||
def name
|
||||
'search_conversations'
|
||||
end
|
||||
|
||||
def description
|
||||
'Search conversations based on parameters'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: properties,
|
||||
required: []
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
status = arguments['status']
|
||||
contact_id = arguments['contact_id']
|
||||
priority = arguments['priority']
|
||||
|
||||
conversations = get_conversations(status, contact_id, priority)
|
||||
|
||||
return 'No conversations found' unless conversations.exists?
|
||||
|
||||
total_count = conversations.count
|
||||
conversations = conversations.limit(100)
|
||||
|
||||
<<~RESPONSE
|
||||
#{total_count > 100 ? "Found #{total_count} conversations (showing first 100)" : "Total number of conversations: #{total_count}"}
|
||||
#{conversations.map { |conversation| conversation.to_llm_text(include_contact_details: true) }.join("\n---\n")}
|
||||
RESPONSE
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_conversations(status, contact_id, priority)
|
||||
conversations = permissible_conversations
|
||||
conversations = conversations.where(contact_id: contact_id) if contact_id.present?
|
||||
conversations = conversations.where(status: status) if status.present?
|
||||
conversations = conversations.where(priority: priority) if priority.present?
|
||||
conversations
|
||||
end
|
||||
|
||||
def permissible_conversations
|
||||
Conversations::PermissionFilterService.new(
|
||||
@assistant.account.conversations,
|
||||
@user,
|
||||
@assistant.account
|
||||
).perform
|
||||
end
|
||||
|
||||
def properties
|
||||
{
|
||||
contact_id: {
|
||||
type: 'number',
|
||||
description: 'Filter conversations by contact ID'
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: %w[open resolved pending snoozed],
|
||||
description: 'Filter conversations by status'
|
||||
},
|
||||
priority: {
|
||||
type: 'string',
|
||||
enum: %w[low medium high urgent],
|
||||
description: 'Filter conversations by priority'
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,77 @@
|
||||
class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseService
|
||||
def name
|
||||
'search_linear_issues'
|
||||
end
|
||||
|
||||
def description
|
||||
'Search Linear issues based on a search term'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
term: {
|
||||
type: 'string',
|
||||
description: 'The search term to find Linear issues'
|
||||
}
|
||||
},
|
||||
required: %w[term]
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
return 'Linear integration is not enabled' unless active?
|
||||
|
||||
term = arguments['term']
|
||||
|
||||
Rails.logger.info "#{self.class.name}: Service called with the search term #{term}"
|
||||
|
||||
return 'Missing required parameters' if term.blank?
|
||||
|
||||
linear_service = Integrations::Linear::ProcessorService.new(account: @assistant.account)
|
||||
result = linear_service.search_issue(term)
|
||||
|
||||
return result[:error] if result[:error]
|
||||
|
||||
issues = result[:data]
|
||||
return 'No issues found, I should try another similar search term' if issues.blank?
|
||||
|
||||
total_count = issues.length
|
||||
|
||||
<<~RESPONSE
|
||||
Total number of issues: #{total_count}
|
||||
#{issues.map { |issue| format_issue(issue) }.join("\n---\n")}
|
||||
RESPONSE
|
||||
end
|
||||
|
||||
def active?
|
||||
@assistant.account.hooks.find_by(app_id: 'linear').present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_issue(issue)
|
||||
<<~ISSUE
|
||||
Title: #{issue['title']}
|
||||
ID: #{issue['id']}
|
||||
State: #{issue['state']['name']}
|
||||
Priority: #{format_priority(issue['priority'])}
|
||||
#{issue['assignee'] ? "Assignee: #{issue['assignee']['name']}" : 'Assignee: Unassigned'}
|
||||
#{issue['description'].present? ? "\nDescription: #{issue['description']}" : ''}
|
||||
ISSUE
|
||||
end
|
||||
|
||||
def format_priority(priority)
|
||||
return 'No priority' if priority.nil?
|
||||
|
||||
case priority
|
||||
when 0 then 'No priority'
|
||||
when 1 then 'Urgent'
|
||||
when 2 then 'High'
|
||||
when 3 then 'Medium'
|
||||
when 4 then 'Low'
|
||||
else 'Unknown'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,36 +1,37 @@
|
||||
module Enterprise::Conversations::PermissionFilterService
|
||||
def perform
|
||||
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id)
|
||||
permissions = account_user&.permissions || []
|
||||
user_role = account_user&.role
|
||||
return filter_by_permissions(permissions) if user_has_custom_role?
|
||||
|
||||
# Skip filtering for administrators
|
||||
return conversations if user_role == 'administrator'
|
||||
# Skip filtering for regular agents (without custom roles/permissions)
|
||||
return conversations if user_role == 'agent' && account_user&.custom_role_id.nil?
|
||||
|
||||
filter_by_permissions(permissions)
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_has_custom_role?
|
||||
user_role == 'agent' && account_user&.custom_role_id.present?
|
||||
end
|
||||
|
||||
def permissions
|
||||
account_user&.permissions || []
|
||||
end
|
||||
|
||||
def filter_by_permissions(permissions)
|
||||
# Permission-based filtering with hierarchy
|
||||
# conversation_manage > conversation_unassigned_manage > conversation_participating_manage
|
||||
if permissions.include?('conversation_manage')
|
||||
conversations
|
||||
accessible_conversations
|
||||
elsif permissions.include?('conversation_unassigned_manage')
|
||||
filter_unassigned_and_mine
|
||||
elsif permissions.include?('conversation_participating_manage')
|
||||
conversations.assigned_to(user)
|
||||
accessible_conversations.assigned_to(user)
|
||||
else
|
||||
Conversation.none
|
||||
end
|
||||
end
|
||||
|
||||
def filter_unassigned_and_mine
|
||||
mine = conversations.assigned_to(user)
|
||||
unassigned = conversations.unassigned
|
||||
mine = accessible_conversations.assigned_to(user)
|
||||
unassigned = accessible_conversations.unassigned
|
||||
|
||||
Conversation.from("(#{mine.to_sql} UNION #{unassigned.to_sql}) as conversations")
|
||||
.where(account_id: account.id)
|
||||
|
||||
+2
-1
@@ -57,7 +57,8 @@ class Linear
|
||||
assigneeId: params[:assignee_id],
|
||||
priority: params[:priority],
|
||||
labelIds: params[:label_ids],
|
||||
projectId: params[:project_id]
|
||||
projectId: params[:project_id],
|
||||
stateId: params[:state_id]
|
||||
}.compact
|
||||
mutation = Linear::Mutations.issue_create(variables)
|
||||
response = post({ query: mutation })
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.1.0",
|
||||
"version": "4.2.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -100,6 +100,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
description: 'This is a sample issue.',
|
||||
assignee_id: 'user1',
|
||||
priority: 'high',
|
||||
state_id: 'state1',
|
||||
label_ids: ['label1']
|
||||
}
|
||||
end
|
||||
|
||||
@@ -2,6 +2,13 @@ require 'rails_helper'
|
||||
|
||||
# Test tool implementation
|
||||
class TestTool < Captain::Tools::BaseService
|
||||
attr_accessor :tool_active
|
||||
|
||||
def initialize(*args)
|
||||
super
|
||||
@tool_active = true
|
||||
end
|
||||
|
||||
def name
|
||||
'test_tool'
|
||||
end
|
||||
@@ -24,6 +31,10 @@ class TestTool < Captain::Tools::BaseService
|
||||
def execute(*args)
|
||||
args
|
||||
end
|
||||
|
||||
def active?
|
||||
@tool_active
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.describe Captain::ToolRegistryService do
|
||||
@@ -40,27 +51,41 @@ RSpec.describe Captain::ToolRegistryService do
|
||||
describe '#register_tool' do
|
||||
let(:tool_class) { TestTool }
|
||||
|
||||
it 'registers a new tool' do
|
||||
service.register_tool(tool_class)
|
||||
|
||||
expect(service.tools['test_tool']).to be_a(TestTool)
|
||||
expect(service.registered_tools).to include(
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'test_tool',
|
||||
description: 'A test tool for specs',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
test_param: {
|
||||
type: 'string'
|
||||
context 'when tool is active' do
|
||||
it 'registers a new tool' do
|
||||
service.register_tool(tool_class)
|
||||
expect(service.tools['test_tool']).to be_a(TestTool)
|
||||
expect(service.registered_tools).to include(
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'test_tool',
|
||||
description: 'A test tool for specs',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
test_param: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when tool is inactive' do
|
||||
it 'does not register the tool' do
|
||||
tool = tool_class.new(assistant)
|
||||
tool.tool_active = false
|
||||
allow(tool_class).to receive(:new).and_return(tool)
|
||||
|
||||
service.register_tool(tool_class)
|
||||
|
||||
expect(service.tools['test_tool']).to be_nil
|
||||
expect(service.registered_tools).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, role: 'administrator', account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:service) { described_class.new(assistant, user: user) }
|
||||
|
||||
describe '#name' do
|
||||
it 'returns the correct service name' do
|
||||
expect(service.name).to eq('search_conversations')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Search conversations based on parameters')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the correct parameter schema' do
|
||||
params = service.parameters
|
||||
expect(params[:type]).to eq('object')
|
||||
expect(params[:properties]).to include(:contact_id, :status, :priority)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let!(:open_conversation) { create(:conversation, account: account, contact: contact, status: 'open', priority: 'high') }
|
||||
let!(:resolved_conversation) { create(:conversation, account: account, status: 'resolved', priority: 'low') }
|
||||
|
||||
it 'returns all conversations when no filters are applied' do
|
||||
result = service.execute({})
|
||||
expect(result).to include('Total number of conversations: 2')
|
||||
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
|
||||
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
|
||||
end
|
||||
|
||||
it 'filters conversations by status' do
|
||||
result = service.execute({ 'status' => 'open' })
|
||||
expect(result).to include('Total number of conversations: 1')
|
||||
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
|
||||
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
|
||||
end
|
||||
|
||||
it 'filters conversations by contact_id' do
|
||||
result = service.execute({ 'contact_id' => contact.id })
|
||||
expect(result).to include('Total number of conversations: 1')
|
||||
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
|
||||
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
|
||||
end
|
||||
|
||||
it 'filters conversations by priority' do
|
||||
result = service.execute({ 'priority' => 'high' })
|
||||
expect(result).to include('Total number of conversations: 1')
|
||||
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
|
||||
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
|
||||
end
|
||||
|
||||
it 'returns appropriate message when no conversations are found' do
|
||||
result = service.execute({ 'status' => 'snoozed' })
|
||||
expect(result).to eq('No conversations found')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:service) { described_class.new(assistant) }
|
||||
|
||||
describe '#name' do
|
||||
it 'returns the correct service name' do
|
||||
expect(service.name).to eq('search_linear_issues')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Search Linear issues based on a search term')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the expected parameter schema' do
|
||||
expect(service.parameters).to eq(
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
term: {
|
||||
type: 'string',
|
||||
description: 'The search term to find Linear issues'
|
||||
}
|
||||
},
|
||||
required: %w[term]
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
context 'when Linear integration is enabled' do
|
||||
before do
|
||||
create(:integrations_hook, :linear, account: account)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear integration is not enabled' do
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when Linear integration is not enabled' do
|
||||
it 'returns error message' do
|
||||
expect(service.execute({ 'term' => 'test' })).to eq('Linear integration is not enabled')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear integration is enabled' do
|
||||
let(:linear_service) { instance_double(Integrations::Linear::ProcessorService) }
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :linear, account: account)
|
||||
allow(Integrations::Linear::ProcessorService).to receive(:new).and_return(linear_service)
|
||||
end
|
||||
|
||||
context 'when term is blank' do
|
||||
it 'returns error message' do
|
||||
expect(service.execute({ 'term' => '' })).to eq('Missing required parameters')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when search returns error' do
|
||||
before do
|
||||
allow(linear_service).to receive(:search_issue).and_return({ error: 'API Error' })
|
||||
end
|
||||
|
||||
it 'returns the error message' do
|
||||
expect(service.execute({ 'term' => 'test' })).to eq('API Error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when search returns no issues' do
|
||||
before do
|
||||
allow(linear_service).to receive(:search_issue).and_return({ data: [] })
|
||||
end
|
||||
|
||||
it 'returns no issues found message' do
|
||||
expect(service.execute({ 'term' => 'test' })).to eq('No issues found, I should try another similar search term')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when search returns issues' do
|
||||
let(:issues) do
|
||||
[{
|
||||
'title' => 'Test Issue',
|
||||
'id' => 'TEST-123',
|
||||
'state' => { 'name' => 'In Progress' },
|
||||
'priority' => 4,
|
||||
'assignee' => { 'name' => 'John Doe' },
|
||||
'description' => 'Test description'
|
||||
}]
|
||||
end
|
||||
|
||||
before do
|
||||
allow(linear_service).to receive(:search_issue).and_return({ data: issues })
|
||||
end
|
||||
|
||||
it 'returns formatted issues' do
|
||||
result = service.execute({ 'term' => 'test' })
|
||||
expect(result).to include('Total number of issues: 1')
|
||||
expect(result).to include('Title: Test Issue')
|
||||
expect(result).to include('ID: TEST-123')
|
||||
expect(result).to include('State: In Progress')
|
||||
expect(result).to include('Priority: Low')
|
||||
expect(result).to include('Assignee: John Doe')
|
||||
expect(result).to include('Description: Test description')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+17
-5
@@ -9,6 +9,8 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let!(:inbox2) { create(:inbox, account: account) }
|
||||
let!(:another_inbox_conversation) { create(:conversation, account: account, inbox: inbox2) }
|
||||
|
||||
# This inbox_member is used to establish the agent's access to the inbox
|
||||
before { create(:inbox_member, user: agent, inbox: inbox) }
|
||||
@@ -25,16 +27,14 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
expect(result).to include(assigned_conversation)
|
||||
expect(result).to include(unassigned_conversation)
|
||||
expect(result).to include(another_assigned_conversation)
|
||||
expect(result.count).to eq(3)
|
||||
expect(result.count).to eq(4)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is a regular agent' do
|
||||
it 'returns all conversations in assigned inboxes' do
|
||||
inbox_ids = agent.inboxes.where(account_id: account.id).pluck(:id)
|
||||
|
||||
result = Conversations::PermissionFilterService.new(
|
||||
account.conversations.where(inbox_id: inbox_ids),
|
||||
account.conversations,
|
||||
agent,
|
||||
account
|
||||
).perform
|
||||
@@ -42,6 +42,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
expect(result).to include(assigned_conversation)
|
||||
expect(result).to include(unassigned_conversation)
|
||||
expect(result).to include(another_assigned_conversation)
|
||||
expect(result).not_to include(another_inbox_conversation)
|
||||
expect(result.count).to eq(3)
|
||||
end
|
||||
end
|
||||
@@ -52,7 +53,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
# Create a new isolated test environment
|
||||
test_account = create(:account)
|
||||
test_inbox = create(:inbox, account: test_account)
|
||||
|
||||
test_inbox2 = create(:inbox, account: test_account)
|
||||
# Create test agent
|
||||
test_agent = create(:user, account: test_account, role: :agent)
|
||||
create(:inbox_member, user: test_agent, inbox: test_inbox)
|
||||
@@ -66,6 +67,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
|
||||
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
|
||||
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
|
||||
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
|
||||
|
||||
# Run the test
|
||||
result = Conversations::PermissionFilterService.new(
|
||||
@@ -79,6 +81,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
expect(result).to include(assigned_conversation)
|
||||
expect(result).to include(unassigned_conversation)
|
||||
expect(result).to include(other_assigned_conversation)
|
||||
expect(result).not_to include(other_inbox_conversation)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -87,6 +90,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
# Create a new isolated test environment
|
||||
test_account = create(:account)
|
||||
test_inbox = create(:inbox, account: test_account)
|
||||
test_inbox2 = create(:inbox, account: test_account)
|
||||
|
||||
# Create test agent
|
||||
test_agent = create(:user, account: test_account, role: :agent)
|
||||
@@ -101,6 +105,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
# Create some conversations
|
||||
other_conversation = create(:conversation, account: test_account, inbox: test_inbox)
|
||||
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
|
||||
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
|
||||
|
||||
# Run the test
|
||||
result = Conversations::PermissionFilterService.new(
|
||||
@@ -114,6 +119,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
expect(result.first.assignee).to eq(test_agent)
|
||||
expect(result).to include(assigned_conversation)
|
||||
expect(result).not_to include(other_conversation)
|
||||
expect(result).not_to include(other_inbox_conversation)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -122,6 +128,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
# Create a new isolated test environment
|
||||
test_account = create(:account)
|
||||
test_inbox = create(:inbox, account: test_account)
|
||||
test_inbox2 = create(:inbox, account: test_account)
|
||||
|
||||
# Create test agent
|
||||
test_agent = create(:user, account: test_account, role: :agent)
|
||||
@@ -137,6 +144,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
|
||||
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
|
||||
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
|
||||
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
|
||||
|
||||
# Run the test
|
||||
result = Conversations::PermissionFilterService.new(
|
||||
@@ -152,6 +160,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
|
||||
# Should NOT include conversations assigned to others
|
||||
expect(result).not_to include(other_assigned_conversation)
|
||||
expect(result).not_to include(other_inbox_conversation)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -160,6 +169,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
# Create a new isolated test environment
|
||||
test_account = create(:account)
|
||||
test_inbox = create(:inbox, account: test_account)
|
||||
test_inbox2 = create(:inbox, account: test_account)
|
||||
|
||||
# Create test agent
|
||||
test_agent = create(:user, account: test_account, role: :agent)
|
||||
@@ -176,6 +186,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
assigned_to_agent = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
|
||||
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
|
||||
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
|
||||
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
|
||||
|
||||
# Run the test
|
||||
result = Conversations::PermissionFilterService.new(
|
||||
@@ -191,6 +202,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
|
||||
expect(result).to include(unassigned_conversation)
|
||||
expect(result).to include(assigned_to_agent)
|
||||
expect(result).not_to include(other_assigned_conversation)
|
||||
expect(result).not_to include(other_inbox_conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -76,6 +76,7 @@ describe Integrations::Linear::ProcessorService do
|
||||
description: 'Issue description',
|
||||
assignee_id: 'user1',
|
||||
priority: 2,
|
||||
state_id: 'state1',
|
||||
label_ids: %w[bug]
|
||||
}
|
||||
end
|
||||
|
||||
@@ -475,4 +475,35 @@ RSpec.describe Message do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#content' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:message) { create(:message, conversation: conversation, content_type: 'input_csat', content: 'Original content') }
|
||||
|
||||
it 'returns original content for web widget inbox' do
|
||||
allow(message.inbox).to receive(:web_widget?).and_return(true)
|
||||
expect(message.content).to eq('Original content')
|
||||
end
|
||||
|
||||
context 'when inbox is not a web widget' do
|
||||
before do
|
||||
allow(message.inbox).to receive(:web_widget?).and_return(false)
|
||||
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com')
|
||||
end
|
||||
|
||||
it 'returns custom message with survey link when csat message is configured' do
|
||||
allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom survey message:' })
|
||||
expected_content = "Custom survey message: https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
|
||||
expect(message.content).to eq(expected_content)
|
||||
end
|
||||
|
||||
it 'returns default message with survey link when no custom csat message' do
|
||||
allow(message.inbox).to receive(:csat_config).and_return(nil)
|
||||
allow(I18n).to receive(:t).with('conversations.survey.response', link: "https://app.chatwoot.com/survey/responses/#{conversation.uuid}")
|
||||
.and_return("Please rate your conversation: https://app.chatwoot.com/survey/responses/#{conversation.uuid}")
|
||||
expected_content = "Please rate your conversation: https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
|
||||
expect(message.content).to eq(expected_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user