From 5138a0ad321a8883b34bb44735ff3184c3d3b54a Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 9 May 2024 19:27:31 +0530 Subject: [PATCH 01/52] feat: Adds support for all snooze option in bulk actions (#9361) * feat: Add support for bulk snooze until * feat: Adds support for all snooze option in bulk actions * chore: Adds comment * chore: Review fixes * chore: Minor fix * chore: Minor fix * chore: Review fixes * chore: yarn changes * fix: terminal waring * chore: Adds spec * Update conversationHotKeys.js --------- Co-authored-by: Muhsin Keloth --- .../dashboard/components/ChatList.vue | 30 ++- .../conversationBulkActions/Index.vue | 63 +++++- .../conversationBulkActions/UpdateActions.vue | 211 ++++++++---------- .../dashboard/i18n/locale/en/bulkActions.json | 2 +- .../i18n/locale/en/generalSettings.json | 1 + .../dashboard/commands/bulkActionsHotKeys.js | 151 +++++++++++++ .../dashboard/commands/commandBarBusEvents.js | 8 + .../routes/dashboard/commands/commandbar.vue | 3 + .../dashboard/store/modules/bulkActions.js | 30 +++ .../modules/specs/bulkActions/actions.spec.js | 24 ++ .../modules/specs/bulkActions/getters.spec.js | 6 + .../specs/bulkActions/mutations.spec.js | 21 ++ .../dashboard/store/mutation-types.js | 3 + app/javascript/v3/components/Form/Button.vue | 2 +- .../v3/components/Form/CheckBox.vue | 2 +- 15 files changed, 423 insertions(+), 134 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/commands/bulkActionsHotKeys.js diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index e7fae6e01..348d8572a 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -288,7 +288,6 @@ export default { foldersQuery: {}, showAddFoldersModal: false, showDeleteFoldersModal: false, - selectedConversations: [], selectedInboxes: [], isContextMenuOpen: false, appliedFilter: [], @@ -329,6 +328,7 @@ export default { inboxesList: 'inboxes/getInboxes', campaigns: 'campaigns/getAllCampaigns', labels: 'labels/getLabels', + selectedConversations: 'bulkActions/getSelectedConversationIds', }), hasAppliedFilters() { return this.appliedFilters.length !== 0; @@ -799,7 +799,7 @@ export default { }); }, resetBulkActions() { - this.selectedConversations = []; + this.$store.dispatch('bulkActions/clearSelectedConversationIds'); this.selectedInboxes = []; }, onBasicFilterChange(value, type) { @@ -830,12 +830,16 @@ export default { return this.selectedConversations.includes(id); }, selectConversation(conversationId, inboxId) { - this.selectedConversations.push(conversationId); + this.$store.dispatch( + 'bulkActions/setSelectedConversationIds', + conversationId + ); this.selectedInboxes.push(inboxId); }, deSelectConversation(conversationId, inboxId) { - this.selectedConversations = this.selectedConversations.filter( - item => item !== conversationId + this.$store.dispatch( + 'bulkActions/removeSelectedConversationIds', + conversationId ); this.selectedInboxes = this.selectedInboxes.filter( item => item !== inboxId @@ -843,7 +847,10 @@ export default { }, selectAllConversations(check) { if (check) { - this.selectedConversations = this.conversationList.map(item => item.id); + this.$store.dispatch( + 'bulkActions/setSelectedConversationIds', + this.conversationList.map(item => item.id) + ); this.selectedInboxes = this.conversationList.map(item => item.inbox_id); } else { this.resetBulkActions(); @@ -859,7 +866,7 @@ export default { assignee_id: agent.id, }, }); - this.selectedConversations = []; + this.$store.dispatch('bulkActions/clearSelectedConversationIds'); if (conversationId) { this.showAlert( this.$t( @@ -957,7 +964,7 @@ export default { add: labels, }, }); - this.selectedConversations = []; + this.$store.dispatch('bulkActions/clearSelectedConversationIds'); if (conversationId) { this.showAlert( this.$t( @@ -984,13 +991,13 @@ export default { team_id: team.id, }, }); - this.selectedConversations = []; + this.$store.dispatch('bulkActions/clearSelectedConversationIds'); this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_SUCCESFUL')); } catch (err) { this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_FAILED')); } }, - async onUpdateConversations(status) { + async onUpdateConversations(status, snoozedUntil) { try { await this.$store.dispatch('bulkActions/process', { type: 'Conversation', @@ -998,8 +1005,9 @@ export default { fields: { status, }, + snoozed_until: snoozedUntil, }); - this.selectedConversations = []; + this.$store.dispatch('bulkActions/clearSelectedConversationIds'); this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL')); } catch (err) { this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_FAILED')); diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue index 699a42f57..3b398b8c3 100644 --- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue +++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue @@ -95,20 +95,40 @@
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
+ + + + diff --git a/app/javascript/dashboard/routes/dashboard/commands/conversationHotKeys.js b/app/javascript/dashboard/routes/dashboard/commands/conversationHotKeys.js index 5d0b1960d..aaa73c5d4 100644 --- a/app/javascript/dashboard/routes/dashboard/commands/conversationHotKeys.js +++ b/app/javascript/dashboard/routes/dashboard/commands/conversationHotKeys.js @@ -55,11 +55,15 @@ export default { replyMode() { this.setCommandbarData(); }, + contextMenuChatId() { + this.setCommandbarData(); + }, }, computed: { ...mapGetters({ currentChat: 'getSelectedChat', replyMode: 'draftMessages/getReplyEditorMode', + contextMenuChatId: 'getContextMenuChatId', }), draftMessage() { return this.$store.getters['draftMessages/get'](this.draftKey); @@ -93,6 +97,7 @@ export default { } return this.prepareActions(actions); }, + priorityOptions() { return [ { @@ -327,25 +332,42 @@ export default { ]; }, - conversationHotKeys() { - if ( + isConversationOrInboxRoute() { + return ( isAConversationRoute(this.$route.name) || isAInboxViewRoute(this.$route.name) - ) { - const defaultConversationHotKeys = [ - ...this.statusActions, - ...this.conversationAdditionalActions, - ...this.assignAgentActions, - ...this.assignTeamActions, - ...this.labelActions, - ...this.assignPriorityActions, - ]; - if (this.isAIIntegrationEnabled) { - return [...defaultConversationHotKeys, ...this.AIAssistActions]; - } - return defaultConversationHotKeys; - } + ); + }, + shouldShowSnoozeOption() { + return ( + isAConversationRoute(this.$route.name, true, false) && + this.contextMenuChatId + ); + }, + + getDefaultConversationHotKeys() { + const defaultConversationHotKeys = [ + ...this.statusActions, + ...this.conversationAdditionalActions, + ...this.assignAgentActions, + ...this.assignTeamActions, + ...this.labelActions, + ...this.assignPriorityActions, + ]; + if (this.isAIIntegrationEnabled) { + return [...defaultConversationHotKeys, ...this.AIAssistActions]; + } + return defaultConversationHotKeys; + }, + + conversationHotKeys() { + if (this.shouldShowSnoozeOption) { + return this.prepareActions(SNOOZE_CONVERSATION_ACTIONS); + } + if (this.isConversationOrInboxRoute) { + return this.getDefaultConversationHotKeys; + } return []; }, }, diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index 0a9d23c6b..12877078a 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -466,6 +466,10 @@ const actions = { commit(types.ASSIGN_PRIORITY, { priority, conversationId }); }, + setContextMenuChatId({ commit }, chatId) { + commit(types.SET_CONTEXT_MENU_CHAT_ID, chatId); + }, + ...messageReadActions, ...messageTranslateActions, }; diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js index 62e9ef4ef..4ba517616 100644 --- a/app/javascript/dashboard/store/modules/conversations/getters.js +++ b/app/javascript/dashboard/store/modules/conversations/getters.js @@ -100,6 +100,10 @@ const getters = { getConversationLastSeen: _state => { return _state.conversationLastSeen; }, + + getContextMenuChatId: _state => { + return _state.contextMenuChatId; + }, }; export default getters; diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index 6bce92e02..83250fa38 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -15,6 +15,7 @@ const state = { currentInbox: null, selectedChatId: null, appliedFilters: [], + contextMenuChatId: null, conversationParticipants: [], conversationLastSeen: null, syncConversationsMessages: {}, @@ -281,6 +282,10 @@ export const mutations = { ) { _state.syncConversationsMessages[conversationId] = messageId; }, + + [types.SET_CONTEXT_MENU_CHAT_ID](_state, chatId) { + _state.contextMenuChatId = chatId; + }, }; export default { diff --git a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js index 648ab2a46..ad42d24f7 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js @@ -652,4 +652,11 @@ describe('#addMentions', () => { ]); }); }); + + describe('#setContextMenuChatId', () => { + it('sets the context menu chat id', () => { + actions.setContextMenuChatId({ commit }, 1); + expect(commit.mock.calls).toEqual([[types.SET_CONTEXT_MENU_CHAT_ID, 1]]); + }); + }); }); diff --git a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js index 1a2225da6..6126692e3 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js @@ -272,4 +272,11 @@ describe('#getters', () => { ]); }); }); + + describe('#getContextMenuChatId', () => { + it('returns the context menu chat id', () => { + const state = { contextMenuChatId: 1 }; + expect(getters.getContextMenuChatId(state)).toEqual(1); + }); + }); }); diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js index 279b36872..93a617ddd 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js @@ -403,4 +403,12 @@ describe('#mutations', () => { expect(state.allConversations[0].attachments).toHaveLength(1); }); }); + + describe('#SET_CONTEXT_MENU_CHAT_ID', () => { + it('sets the context menu chat id', () => { + const state = { contextMenuChatId: 1 }; + mutations[types.SET_CONTEXT_MENU_CHAT_ID](state, 2); + expect(state.contextMenuChatId).toEqual(2); + }); + }); }); diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 40db99212..e424d56b8 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -58,6 +58,8 @@ export default { SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY', + SET_CONTEXT_MENU_CHAT_ID: 'SET_CONTEXT_MENU_CHAT_ID', + // Inboxes SET_INBOXES_UI_FLAG: 'SET_INBOXES_UI_FLAG', SET_INBOXES: 'SET_INBOXES', diff --git a/package.json b/package.json index 1063ddf16..78fb2fed1 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "markdown-it": "^13.0.2", "markdown-it-link-attributes": "^4.0.1", "md5": "^2.3.0", - "ninja-keys": "^1.2.2", + "@chatwoot/ninja-keys": "1.2.3", "opus-recorder": "^8.0.5", "postcss": "^8.4.31", "postcss-loader": "^4.2.0", diff --git a/yarn.lock b/yarn.lock index 0aefd8413..daecee4b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3156,6 +3156,15 @@ "@braid/vue-formulate-i18n" "^1.16.0" is-plain-object "^3.0.1" +"@chatwoot/ninja-keys@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@chatwoot/ninja-keys/-/ninja-keys-1.2.3.tgz#3c3f2b505f091ef4707fd1da39bb2ec6d12e7824" + integrity sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g== + dependencies: + "@material/mwc-icon" "0.25.3" + hotkeys-js "3.8.7" + lit "2.2.6" + "@chatwoot/prosemirror-schema@1.0.5": version "1.0.5" resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.5.tgz#d6053692beae59d466ac0b04128fa157f59eb176" @@ -15083,15 +15092,6 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -ninja-keys@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/ninja-keys/-/ninja-keys-1.2.2.tgz#c1e1ec1a98aee3a977ee77157ac4aa865348be88" - integrity sha512-ylo8jzKowi3XBHkgHRjBJaKQkl32WRLr7kRiA0ajiku11vHRDJ2xANtTScR5C7XlDwKEOYvUPesCKacUeeLAYw== - dependencies: - "@material/mwc-icon" "0.25.3" - hotkeys-js "3.8.7" - lit "2.2.6" - no-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" From 023b3ad50783a359f91a5d54eae183b9d0814c83 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 22 May 2024 13:37:58 +0530 Subject: [PATCH 33/52] feat: Add APIs for linear integration (#9346) --- .../integrations/linear_controller.rb | 93 ++++++ .../settings/integrationapps/Index.vue | 58 ++-- .../dashboard/store/modules/integrations.js | 10 +- config/features.yml | 2 + config/integration/apps.yml | 24 ++ config/locales/en.yml | 3 + config/routes.rb | 11 + lib/integrations/linear/processor_service.rb | 82 +++++ lib/linear.rb | 120 +++++++ lib/linear/mutations.rb | 56 ++++ lib/linear/queries.rb | 100 ++++++ .../dashboard/images/integrations/linear.png | Bin 0 -> 1449 bytes .../integrations/linear_controller_spec.rb | 257 +++++++++++++++ spec/factories/integrations/hooks.rb | 5 + .../linear/processor_service_spec.rb | 209 ++++++++++++ spec/lib/linear_spec.rb | 302 ++++++++++++++++++ 16 files changed, 1308 insertions(+), 24 deletions(-) create mode 100644 app/controllers/api/v1/accounts/integrations/linear_controller.rb create mode 100644 lib/integrations/linear/processor_service.rb create mode 100644 lib/linear.rb create mode 100644 lib/linear/mutations.rb create mode 100644 lib/linear/queries.rb create mode 100644 public/dashboard/images/integrations/linear.png create mode 100644 spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb create mode 100644 spec/lib/integrations/linear/processor_service_spec.rb create mode 100644 spec/lib/linear_spec.rb diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb new file mode 100644 index 000000000..9d5d76d75 --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -0,0 +1,93 @@ +class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController + before_action :fetch_conversation, only: [:link_issue, :linked_issues] + + def teams + teams = linear_processor_service.teams + if teams[:error] + render json: { error: teams[:error] }, status: :unprocessable_entity + else + render json: teams[:data], status: :ok + end + end + + def team_entities + team_id = permitted_params[:team_id] + team_entities = linear_processor_service.team_entities(team_id) + if team_entities[:error] + render json: { error: team_entities[:error] }, status: :unprocessable_entity + else + render json: team_entities[:data], status: :ok + end + end + + def create_issue + issue = linear_processor_service.create_issue(permitted_params) + if issue[:error] + render json: { error: issue[:error] }, status: :unprocessable_entity + else + render json: issue[:data], status: :ok + end + end + + def link_issue + issue_id = permitted_params[:issue_id] + title = permitted_params[:title] + issue = linear_processor_service.link_issue(conversation_link, issue_id, title) + if issue[:error] + render json: { error: issue[:error] }, status: :unprocessable_entity + else + render json: issue[:data], status: :ok + end + end + + def unlink_issue + link_id = permitted_params[:link_id] + issue = linear_processor_service.unlink_issue(link_id) + + if issue[:error] + render json: { error: issue[:error] }, status: :unprocessable_entity + else + render json: issue[:data], status: :ok + end + end + + def linked_issues + issues = linear_processor_service.linked_issues(conversation_link) + + if issues[:error] + render json: { error: issues[:error] }, status: :unprocessable_entity + else + render json: issues[:data], status: :ok + end + end + + def search_issue + render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return + + term = params[:q] + issues = linear_processor_service.search_issue(term) + if issues[:error] + render json: { error: issues[:error] }, status: :unprocessable_entity + else + render json: issues[:data], status: :ok + end + end + + private + + def conversation_link + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/conversations/#{@conversation.display_id}" + end + + def fetch_conversation + @conversation = Current.account.conversations.find_by!(display_id: permitted_params[:conversation_id]) + end + + def linear_processor_service + Integrations::Linear::ProcessorService.new(account: Current.account) + end + + def permitted_params + params.permit(:team_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: []) + end +end diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/Index.vue index 8e63ea3a4..22ba37105 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/Index.vue @@ -1,16 +1,16 @@ - diff --git a/app/javascript/dashboard/store/modules/integrations.js b/app/javascript/dashboard/store/modules/integrations.js index 477c02b03..3183afbe3 100644 --- a/app/javascript/dashboard/store/modules/integrations.js +++ b/app/javascript/dashboard/store/modules/integrations.js @@ -21,9 +21,13 @@ const state = { }; const isAValidAppIntegration = integration => { - return ['dialogflow', 'dyte', 'google_translate', 'openai'].includes( - integration.id - ); + return [ + 'dialogflow', + 'dyte', + 'google_translate', + 'openai', + 'linear', + ].includes(integration.id); }; export const getters = { getIntegrations($state) { diff --git a/config/features.yml b/config/features.yml index 42714bc73..98c4f608d 100644 --- a/config/features.yml +++ b/config/features.yml @@ -83,3 +83,5 @@ - name: help_center_embedding_search enabled: false premium: true +- name: linear_integration + enabled: false diff --git a/config/integration/apps.yml b/config/integration/apps.yml index 408c56e1c..538f7ee81 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -159,3 +159,27 @@ openai: }, ] visible_properties: ['api_key', 'label_suggestion'] +linear: + id: linear + logo: linear.png + i18n_key: linear + action: /linear + hook_type: account + allow_multiple_hooks: false + settings_json_schema: { + "type": "object", + "properties": { + "api_key": { "type": "string" }, + }, + "required": ["api_key"], + "additionalProperties": false, + } + settings_form_schema: [ + { + "label": "API Key", + "type": "text", + "name": "api_key", + "validation": "required", + }, + ] + visible_properties: [] diff --git a/config/locales/en.yml b/config/locales/en.yml index 44c4d76cb..dae3b36e0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -224,6 +224,9 @@ en: openai: name: "OpenAI" description: "Integrate powerful AI features into Chatwoot by leveraging the GPT models from OpenAI." + linear: + name: "Linear" + description: "Create Linear issues from conversations, or link existing ones for seamless tracking." public_portal: search: search_placeholder: Search for article by title or body... diff --git a/config/routes.rb b/config/routes.rb index 0593b9e16..fd059a9e4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -227,6 +227,17 @@ Rails.application.routes.draw do post :add_participant_to_meeting end end + resource :linear, controller: 'linear', only: [] do + collection do + get :teams + get :team_entities + post :create_issue + post :link_issue + post :unlink_issue + get :search_issue + get :linked_issues + end + end end resources :working_hours, only: [:update] diff --git a/lib/integrations/linear/processor_service.rb b/lib/integrations/linear/processor_service.rb new file mode 100644 index 000000000..20b04a301 --- /dev/null +++ b/lib/integrations/linear/processor_service.rb @@ -0,0 +1,82 @@ +class Integrations::Linear::ProcessorService + pattr_initialize [:account!] + + def teams + response = linear_client.teams + return { error: response[:error] } if response[:error] + + { data: response['teams']['nodes'].map(&:as_json) } + end + + def team_entities(team_id) + response = linear_client.team_entities(team_id) + return response if response[:error] + + { + data: { + users: response['users']['nodes'].map(&:as_json), + projects: response['projects']['nodes'].map(&:as_json), + states: response['workflowStates']['nodes'].map(&:as_json), + labels: response['issueLabels']['nodes'].map(&:as_json) + } + } + end + + def create_issue(params) + response = linear_client.create_issue(params) + return response if response[:error] + + { + data: { id: response['issueCreate']['issue']['id'], + title: response['issueCreate']['issue']['title'] } + } + end + + def link_issue(link, issue_id, title) + response = linear_client.link_issue(link, issue_id, title) + return response if response[:error] + + { + data: { + id: issue_id, + link: link, + link_id: response.with_indifferent_access[:attachmentLinkURL][:attachment][:id] + } + } + end + + def unlink_issue(link_id) + response = linear_client.unlink_issue(link_id) + return response if response[:error] + + { + data: { link_id: link_id } + } + end + + def search_issue(term) + response = linear_client.search_issue(term) + + return response if response[:error] + + { data: response['searchIssues']['nodes'].map(&:as_json) } + end + + def linked_issues(url) + response = linear_client.linked_issues(url) + return response if response[:error] + + { data: response['attachmentsForURL']['nodes'].map(&:as_json) } + end + + private + + def linear_hook + @linear_hook ||= account.hooks.find_by!(app_id: 'linear') + end + + def linear_client + credentials = linear_hook.settings + @linear_client ||= Linear.new(credentials['api_key']) + end +end diff --git a/lib/linear.rb b/lib/linear.rb new file mode 100644 index 000000000..63489b060 --- /dev/null +++ b/lib/linear.rb @@ -0,0 +1,120 @@ +class Linear + BASE_URL = 'https://api.linear.app/graphql'.freeze + PRIORITY_LEVELS = (0..4).to_a + + def initialize(api_key) + @api_key = api_key + raise ArgumentError, 'Missing Credentials' if api_key.blank? + end + + def teams + query = { + query: Linear::Queries::TEAMS_QUERY + } + response = post(query) + process_response(response) + end + + def team_entities(team_id) + raise ArgumentError, 'Missing team id' if team_id.blank? + + query = { + query: Linear::Queries.team_entities_query(team_id) + } + response = post(query) + process_response(response) + end + + def search_issue(term) + raise ArgumentError, 'Missing search term' if term.blank? + + query = { + query: Linear::Queries.search_issue(term) + } + response = post(query) + process_response(response) + end + + def linked_issues(url) + raise ArgumentError, 'Missing link' if url.blank? + + query = { + query: Linear::Queries.linked_issues(url) + } + response = post(query) + process_response(response) + end + + def create_issue(params) + validate_team_and_title(params) + validate_priority(params[:priority]) + validate_label_ids(params[:label_ids]) + + variables = { + title: params[:title], + teamId: params[:team_id], + description: params[:description], + assigneeId: params[:assignee_id], + priority: params[:priority], + labelIds: params[:label_ids] + }.compact + mutation = Linear::Mutations.issue_create(variables) + response = post({ query: mutation }) + process_response(response) + end + + def link_issue(link, issue_id, title) + raise ArgumentError, 'Missing link' if link.blank? + raise ArgumentError, 'Missing issue id' if issue_id.blank? + + payload = { + query: Linear::Mutations.issue_link(issue_id, link, title) + } + response = post(payload) + process_response(response) + end + + def unlink_issue(link_id) + raise ArgumentError, 'Missing link id' if link_id.blank? + + payload = { + query: Linear::Mutations.unlink_issue(link_id) + } + response = post(payload) + process_response(response) + end + + private + + def validate_team_and_title(params) + raise ArgumentError, 'Missing team id' if params[:team_id].blank? + raise ArgumentError, 'Missing title' if params[:title].blank? + end + + def validate_priority(priority) + return if priority.nil? || PRIORITY_LEVELS.include?(priority) + + raise ArgumentError, 'Invalid priority value. Priority must be 0, 1, 2, 3, or 4.' + end + + def validate_label_ids(label_ids) + return if label_ids.nil? + return if label_ids.is_a?(Array) && label_ids.all?(String) + + raise ArgumentError, 'label_ids must be an array of strings.' + end + + def post(payload) + HTTParty.post( + BASE_URL, + headers: { 'Authorization' => @api_key, 'Content-Type' => 'application/json' }, + body: payload.to_json + ) + end + + def process_response(response) + return response.parsed_response['data'].with_indifferent_access if response.success? && !response.parsed_response['data'].nil? + + { error: response.parsed_response, error_code: response.code } + end +end diff --git a/lib/linear/mutations.rb b/lib/linear/mutations.rb new file mode 100644 index 000000000..f887c39c8 --- /dev/null +++ b/lib/linear/mutations.rb @@ -0,0 +1,56 @@ +module Linear::Mutations + def self.graphql_value(value) + case value + when String + # Strings must be enclosed in double quotes + "\"#{value}\"" + when Array + # Arrays need to be recursively converted + "[#{value.map { |v| graphql_value(v) }.join(', ')}]" + else + # Other types (numbers, booleans) can be directly converted to strings + value.to_s + end + end + + def self.graphql_input(input) + input.map { |key, value| "#{key}: #{graphql_value(value)}" }.join(', ') + end + + def self.issue_create(input) + <<~GRAPHQL + mutation { + issueCreate(input: { #{graphql_input(input)} }) { + success + issue { + id + title + } + } + } + GRAPHQL + end + + def self.issue_link(issue_id, link, title) + <<~GRAPHQL + mutation { + attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}", title: "#{title}") { + success + attachment { + id + } + } + } + GRAPHQL + end + + def self.unlink_issue(link_id) + <<~GRAPHQL + mutation { + attachmentDelete(id: "#{link_id}") { + success + } + } + GRAPHQL + end +end diff --git a/lib/linear/queries.rb b/lib/linear/queries.rb new file mode 100644 index 000000000..78b2108f9 --- /dev/null +++ b/lib/linear/queries.rb @@ -0,0 +1,100 @@ +module Linear::Queries + TEAMS_QUERY = <<~GRAPHQL.freeze + query { + teams { + nodes { + id + name + } + } + } + GRAPHQL + + def self.team_entities_query(team_id) + <<~GRAPHQL + query { + users { + nodes { + id + name + } + } + projects { + nodes { + id + name + } + } + workflowStates( + filter: { team: { id: { eq: "#{team_id}" } } } + ) { + nodes { + id + name + } + } + issueLabels( + filter: { team: { id: { eq: "#{team_id}" } } } + ) { + nodes { + id + name + } + } + } + GRAPHQL + end + + def self.search_issue(term) + <<~GRAPHQL + query { + searchIssues(term: "#{term}") { + nodes { + id + title + description + identifier + } + } + } + GRAPHQL + end + + def self.linked_issues(url) + <<~GRAPHQL + query { + attachmentsForURL(url: "#{url}") { + nodes { + id + title + issue { + id + identifier + title + description + priority + createdAt + url + assignee { + name + avatarUrl + } + state { + name + color + } + labels { + nodes{ + id + name + color + description + } + } + } + } + } + } + GRAPHQL + end +end diff --git a/public/dashboard/images/integrations/linear.png b/public/dashboard/images/integrations/linear.png new file mode 100644 index 0000000000000000000000000000000000000000..1921f4c3f3987045c61bd1656e5e1446555c8382 GIT binary patch literal 1449 zcmV;a1y=frP)uIy^%+B^ByhXt}Qf$rOnn?i^Kl(q>4f(5!{7PP(hYnQM93K#g|b-&3N+lRR- zxd42;4IKU2TJxYl3KRI#+na5R!!ayg+&qPf%w3@S>XRev;d#+jH7SgN$10Vhyzh|~ zt~mZwFG4N}%`t`8A&S5=sOPGwXYAZjKY4ma!4RJ{1n) z&!I%v%yQ4qzHh4@pS>GXK8XWtR5nx_73C^Son=|Nhhxl=Rp*)*4wVB!Ngxe>`0YP` zy?pcgo-Ds?5@{$(({C!rDhb4Ten3A*f_u715_$1;E3IBWBYT6(>v0ezm5_|V5%?s(Y7t=j;PQG#I?9>dMMjS3>eT8X|fEdz+g3w z0iEC&iNTb9=zhHL2o&PC$*`26_@)P(n3W-unREa(!Rv<;8#YEUQQCf*A(KkL$5iD#cVjFAZcB$RheGi*6X@O}U zZ~JUbTHs|p6-KWOXt&mORE|}iOkiRZwLP>nk;v#J7S)mRR#^3?vsxQHtjeXA$b~qH z>i5l~T!p8^RApj?t1~;jXgzO20us*$vdU;DEE*=PWF*q(TGhKlNJwIVanDUpSSgA0 zd92E}#{J%8B(B0!;;PhyM_-+1^Ay`_>l(@hm=lYt^x^#D!mkmR(13&%e^j)+yzdd8?^v& z5K^FpNo3>q3%*mCSxG}-0tqS*w=5U`lRb&ZLM7r+HT(ikX*w=zLlH%x0%r*#lA*|l z!@y!BHoLVVr9HR~STGbJz^rXCeg)_zjy$ca%gt_Xy8Dge9_YfwNGg(9gJN z!PXsT#o0a!UPMcenXw+@q7P5`Wf2X7!C){L3=82iC>N@TJG(NT00000NkvXXu0mjf D5?7!i literal 0 HcmV?d00001 diff --git a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb new file mode 100644 index 000000000..8b5de48dd --- /dev/null +++ b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb @@ -0,0 +1,257 @@ +require 'rails_helper' + +RSpec.describe 'Linear Integration API', type: :request do + let(:account) { create(:account) } + let(:user) { create(:user) } + let(:api_key) { 'valid_api_key' } + let(:agent) { create(:user, account: account, role: :agent) } + let(:processor_service) { instance_double(Integrations::Linear::ProcessorService) } + + before do + create(:integrations_hook, :linear, account: account) + allow(Integrations::Linear::ProcessorService).to receive(:new).with(account: account).and_return(processor_service) + end + + describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do + context 'when it is an authenticated user' do + context 'when data is retrieved successfully' do + let(:teams_data) { { data: [{ 'id' => 'team1', 'name' => 'Team One' }] } } + + it 'returns team data' do + allow(processor_service).to receive(:teams).and_return(teams_data) + get "/api/v1/accounts/#{account.id}/integrations/linear/teams", + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:ok) + expect(response.body).to include('Team One') + end + end + + context 'when data retrieval fails' do + it 'returns error message' do + allow(processor_service).to receive(:teams).and_return(error: 'error message') + get "/api/v1/accounts/#{account.id}/integrations/linear/teams", + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('error message') + end + end + end + end + + describe 'GET /api/v1/accounts/:account_id/integrations/linear/team_entities' do + let(:team_id) { 'team1' } + + context 'when it is an authenticated user' do + context 'when data is retrieved successfully' do + let(:team_entities_data) do + { data: { + users: [{ 'id' => 'user1', 'name' => 'User One' }], + projects: [{ 'id' => 'project1', 'name' => 'Project One' }], + states: [{ 'id' => 'state1', 'name' => 'State One' }], + labels: [{ 'id' => 'label1', 'name' => 'Label One' }] + } } + end + + it 'returns team entities data' do + allow(processor_service).to receive(:team_entities).with(team_id).and_return(team_entities_data) + get "/api/v1/accounts/#{account.id}/integrations/linear/team_entities", + params: { team_id: team_id }, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:ok) + expect(response.body).to include('User One') + expect(response.body).to include('Project One') + expect(response.body).to include('State One') + expect(response.body).to include('Label One') + end + end + + context 'when data retrieval fails' do + it 'returns error message' do + allow(processor_service).to receive(:team_entities).with(team_id).and_return(error: 'error message') + get "/api/v1/accounts/#{account.id}/integrations/linear/team_entities", + params: { team_id: team_id }, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('error message') + end + end + end + end + + describe 'POST /api/v1/accounts/:account_id/integrations/linear/create_issue' do + let(:issue_params) do + { + team_id: 'team1', + title: 'Sample Issue', + description: 'This is a sample issue.', + assignee_id: 'user1', + priority: 'high', + label_ids: ['label1'] + } + end + + context 'when it is an authenticated user' do + context 'when the issue is created successfully' do + let(:created_issue) { { data: { 'id' => 'issue1', 'title' => 'Sample Issue' } } } + + it 'returns the created issue' do + allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(created_issue) + post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue", + params: issue_params, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:ok) + expect(response.body).to include('Sample Issue') + end + end + + context 'when issue creation fails' do + it 'returns error message' do + allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(error: 'error message') + post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue", + params: issue_params, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('error message') + end + end + end + end + + describe 'POST /api/v1/accounts/:account_id/integrations/linear/link_issue' do + let(:issue_id) { 'issue1' } + let(:conversation) { create(:conversation, account: account) } + let(:link) { "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/conversations/#{conversation.display_id}" } + let(:title) { 'Sample Issue' } + + context 'when it is an authenticated user' do + context 'when the issue is linked successfully' do + let(:linked_issue) { { data: { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } } } + + it 'returns the linked issue' do + allow(processor_service).to receive(:link_issue).with(link, issue_id, title).and_return(linked_issue) + post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue", + params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title }, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:ok) + expect(response.body).to include('https://linear.app/issue1') + end + end + + context 'when issue linking fails' do + it 'returns error message' do + allow(processor_service).to receive(:link_issue).with(link, issue_id, title).and_return(error: 'error message') + post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue", + params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title }, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('error message') + end + end + end + end + + describe 'POST /api/v1/accounts/:account_id/integrations/linear/unlink_issue' do + let(:link_id) { 'attachment1' } + + context 'when it is an authenticated user' do + context 'when the issue is unlinked successfully' do + let(:unlinked_issue) { { data: { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } } } + + it 'returns the unlinked issue' do + allow(processor_service).to receive(:unlink_issue).with(link_id).and_return(unlinked_issue) + post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue", + params: { link_id: link_id }, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:ok) + expect(response.body).to include('https://linear.app/issue1') + end + end + + context 'when issue unlinking fails' do + it 'returns error message' do + allow(processor_service).to receive(:unlink_issue).with(link_id).and_return(error: 'error message') + post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue", + params: { link_id: link_id }, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('error message') + end + end + end + end + + describe 'GET /api/v1/accounts/:account_id/integrations/linear/search_issue' do + let(:term) { 'issue' } + + context 'when it is an authenticated user' do + context 'when search is successful' do + let(:search_results) { { data: [{ 'id' => 'issue1', 'title' => 'Sample Issue' }] } } + + it 'returns search results' do + allow(processor_service).to receive(:search_issue).with(term).and_return(search_results) + get "/api/v1/accounts/#{account.id}/integrations/linear/search_issue", + params: { q: term }, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:ok) + expect(response.body).to include('Sample Issue') + end + end + + context 'when search fails' do + it 'returns error message' do + allow(processor_service).to receive(:search_issue).with(term).and_return(error: 'error message') + get "/api/v1/accounts/#{account.id}/integrations/linear/search_issue", + params: { q: term }, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('error message') + end + end + end + end + + describe 'GET /api/v1/accounts/:account_id/integrations/linear/linked_issues' do + let(:conversation) { create(:conversation, account: account) } + let(:link) { "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/conversations/#{conversation.display_id}" } + + context 'when it is an authenticated user' do + context 'when linked issue is found' do + let(:linked_issue) { { data: [{ 'id' => 'issue1', 'title' => 'Sample Issue' }] } } + + it 'returns linked issue' do + allow(processor_service).to receive(:linked_issues).with(link).and_return(linked_issue) + get "/api/v1/accounts/#{account.id}/integrations/linear/linked_issues", + params: { conversation_id: conversation.display_id }, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:ok) + expect(response.body).to include('Sample Issue') + end + end + + context 'when linked issue is not found' do + it 'returns error message' do + allow(processor_service).to receive(:linked_issues).with(link).and_return(error: 'error message') + get "/api/v1/accounts/#{account.id}/integrations/linear/linked_issues", + params: { conversation_id: conversation.display_id }, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('error message') + end + end + end + end +end diff --git a/spec/factories/integrations/hooks.rb b/spec/factories/integrations/hooks.rb index 7219884df..12958c1ac 100644 --- a/spec/factories/integrations/hooks.rb +++ b/spec/factories/integrations/hooks.rb @@ -26,5 +26,10 @@ FactoryBot.define do app_id { 'openai' } settings { { api_key: 'api_key' } } end + + trait :linear do + app_id { 'linear' } + settings { { api_key: 'api_key' } } + end end end diff --git a/spec/lib/integrations/linear/processor_service_spec.rb b/spec/lib/integrations/linear/processor_service_spec.rb new file mode 100644 index 000000000..07cf27654 --- /dev/null +++ b/spec/lib/integrations/linear/processor_service_spec.rb @@ -0,0 +1,209 @@ +require 'rails_helper' + +describe Integrations::Linear::ProcessorService do + let(:account) { create(:account) } + let(:api_key) { 'valid_api_key' } + let(:linear_client) { instance_double(Linear) } + let(:service) { described_class.new(account: account) } + + before do + create(:integrations_hook, :linear, account: account) + allow(Linear).to receive(:new).and_return(linear_client) + end + + describe '#teams' do + context 'when Linear client returns valid data' do + let(:teams_response) do + { 'teams' => { 'nodes' => [{ 'id' => 'team1', 'name' => 'Team One' }] } } + end + + it 'returns parsed team data' do + allow(linear_client).to receive(:teams).and_return(teams_response) + result = service.teams + expect(result).to eq({ data: [{ 'id' => 'team1', 'name' => 'Team One' }] }) + end + end + + context 'when Linear client returns an error' do + let(:error_response) { { error: 'Some error message' } } + + it 'returns the error' do + allow(linear_client).to receive(:teams).and_return(error_response) + result = service.teams + expect(result).to eq(error_response) + end + end + end + + describe '#team_entities' do + let(:team_id) { 'team1' } + let(:entities_response) do + { + 'users' => { 'nodes' => [{ 'id' => 'user1', 'name' => 'User One' }] }, + 'projects' => { 'nodes' => [{ 'id' => 'project1', 'name' => 'Project One' }] }, + 'workflowStates' => { 'nodes' => [] }, + 'issueLabels' => { 'nodes' => [{ 'id' => 'bug', 'name' => 'Bug' }] } + } + end + + context 'when Linear client returns valid data' do + it 'returns parsed entity data' do + allow(linear_client).to receive(:team_entities).with(team_id).and_return(entities_response) + result = service.team_entities(team_id) + expect(result).to eq({ :data => { :users => + [{ 'id' => 'user1', 'name' => 'User One' }], + :projects => [{ 'id' => 'project1', 'name' => 'Project One' }], + :states => [], :labels => [{ 'id' => 'bug', 'name' => 'Bug' }] } }) + end + end + + context 'when Linear client returns an error' do + let(:error_response) { { error: 'Some error message' } } + + it 'returns the error' do + allow(linear_client).to receive(:team_entities).with(team_id).and_return(error_response) + result = service.team_entities(team_id) + expect(result).to eq(error_response) + end + end + end + + describe '#create_issue' do + let(:params) do + { + title: 'Issue title', + team_id: 'team1', + description: 'Issue description', + assignee_id: 'user1', + priority: 2, + label_ids: %w[bug] + } + end + let(:issue_response) do + { + 'issueCreate' => { 'issue' => { 'id' => 'issue1', 'title' => 'Issue title' } } + } + end + + context 'when Linear client returns valid data' do + it 'returns parsed issue data' do + allow(linear_client).to receive(:create_issue).with(params).and_return(issue_response) + result = service.create_issue(params) + expect(result).to eq({ data: { id: 'issue1', title: 'Issue title' } }) + end + end + + context 'when Linear client returns an error' do + let(:error_response) { { error: 'Some error message' } } + + it 'returns the error' do + allow(linear_client).to receive(:create_issue).with(params).and_return(error_response) + result = service.create_issue(params) + expect(result).to eq(error_response) + end + end + end + + describe '#link_issue' do + let(:link) { 'https://example.com' } + let(:issue_id) { 'issue1' } + let(:title) { 'Title' } + let(:link_issue_response) { { id: issue_id, link: link, 'attachmentLinkURL': { 'attachment': { 'id': 'attachment1' } } } } + let(:link_response) { { data: { id: issue_id, link: link, link_id: 'attachment1' } } } + + context 'when Linear client returns valid data' do + it 'returns parsed link data' do + allow(linear_client).to receive(:link_issue).with(link, issue_id, title).and_return(link_issue_response) + result = service.link_issue(link, issue_id, title) + expect(result).to eq(link_response) + end + end + + context 'when Linear client returns an error' do + let(:error_response) { { error: 'Some error message' } } + + it 'returns the error' do + allow(linear_client).to receive(:link_issue).with(link, issue_id, title).and_return(error_response) + result = service.link_issue(link, issue_id, title) + expect(result).to eq(error_response) + end + end + end + + describe '#unlink_issue' do + let(:link_id) { 'attachment1' } + let(:unlink_response) { { data: { link_id: link_id } } } + + context 'when Linear client returns valid data' do + it 'returns parsed unlink data' do + allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(unlink_response) + result = service.unlink_issue(link_id) + expect(result).to eq(unlink_response) + end + end + + context 'when Linear client returns an error' do + let(:error_response) { { error: 'Some error message' } } + + it 'returns the error' do + allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(error_response) + result = service.unlink_issue(link_id) + expect(result).to eq(error_response) + end + end + end + + describe '#search_issue' do + let(:term) { 'search term' } + let(:search_response) do + { + 'searchIssues' => { 'nodes' => [{ 'id' => 'issue1', 'title' => 'Issue title', 'description' => 'Issue description' }] } + } + end + + context 'when Linear client returns valid data' do + it 'returns parsed search data' do + allow(linear_client).to receive(:search_issue).with(term).and_return(search_response) + result = service.search_issue(term) + expect(result).to eq({ :data => [{ 'description' => 'Issue description', 'id' => 'issue1', 'title' => 'Issue title' }] }) + end + end + + context 'when Linear client returns an error' do + let(:error_response) { { error: 'Some error message' } } + + it 'returns the error' do + allow(linear_client).to receive(:search_issue).with(term).and_return(error_response) + result = service.search_issue(term) + expect(result).to eq(error_response) + end + end + end + + describe '#linked_issues' do + let(:url) { 'https://example.com' } + let(:linked_response) do + { + 'attachmentsForURL' => { 'nodes' => [{ 'id' => 'attachment1', :issue => { 'id' => 'issue1' } }] } + } + end + + context 'when Linear client returns valid data' do + it 'returns parsed linked data' do + allow(linear_client).to receive(:linked_issues).with(url).and_return(linked_response) + result = service.linked_issues(url) + expect(result).to eq({ :data => [{ 'id' => 'attachment1', 'issue' => { 'id' => 'issue1' } }] }) + end + end + + context 'when Linear client returns an error' do + let(:error_response) { { error: 'Some error message' } } + + it 'returns the error' do + allow(linear_client).to receive(:linked_issues).with(url).and_return(error_response) + result = service.linked_issues(url) + expect(result).to eq(error_response) + end + end + end +end diff --git a/spec/lib/linear_spec.rb b/spec/lib/linear_spec.rb new file mode 100644 index 000000000..a81313d6c --- /dev/null +++ b/spec/lib/linear_spec.rb @@ -0,0 +1,302 @@ +require 'rails_helper' + +describe Linear do + let(:api_key) { 'valid_api_key' } + let(:url) { 'https://api.linear.app/graphql' } + let(:linear_client) { described_class.new(api_key) } + let(:headers) { { 'Content-Type' => 'application/json', 'Authorization' => api_key } } + + it 'raises an exception if the API key is absent' do + expect { described_class.new(nil) }.to raise_error(ArgumentError, 'Missing Credentials') + end + + context 'when querying teams' do + context 'when the API response is success' do + before do + stub_request(:post, url) + .to_return(status: 200, + body: { success: true, data: { teams: { nodes: [{ id: 'team1', name: 'Team One' }] } } }.to_json, + headers: headers) + end + + it 'returns team data' do + response = linear_client.teams + expect(response).to eq({ 'teams' => { 'nodes' => [{ 'id' => 'team1', 'name' => 'Team One' }] } }) + end + end + + context 'when the API response is an error' do + before do + stub_request(:post, url) + .to_return(status: 422, body: { errors: [{ message: 'Error retrieving data' }] }.to_json, + headers: headers) + end + + it 'raises an exception' do + response = linear_client.teams + expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error retrieving data' }] }, :error_code => 422 }) + end + end + end + + context 'when querying team entities' do + let(:team_id) { 'team1' } + + context 'when the API response is success' do + before do + stub_request(:post, url) + .to_return(status: 200, + body: { success: true, data: { + users: { nodes: [{ id: 'user1', name: 'User One' }] }, + projects: { nodes: [{ id: 'project1', name: 'Project One' }] }, + workflowStates: { nodes: [] }, + issueLabels: { nodes: [{ id: 'bug', name: 'Bug' }] } + } }.to_json, + headers: headers) + end + + it 'returns team entities' do + response = linear_client.team_entities(team_id) + expect(response).to eq({ + 'users' => { 'nodes' => [{ 'id' => 'user1', 'name' => 'User One' }] }, + 'projects' => { 'nodes' => [{ 'id' => 'project1', 'name' => 'Project One' }] }, + 'workflowStates' => { 'nodes' => [] }, + 'issueLabels' => { 'nodes' => [{ 'id' => 'bug', 'name' => 'Bug' }] } + }) + end + end + + context 'when the API response is an error' do + before do + stub_request(:post, url) + .to_return(status: 422, body: { errors: [{ message: 'Error retrieving data' }] }.to_json, + headers: headers) + end + + it 'raises an exception' do + response = linear_client.team_entities(team_id) + expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error retrieving data' }] }, :error_code => 422 }) + end + end + end + + context 'when creating an issue' do + let(:params) do + { + title: 'Title', + team_id: 'team1', + description: 'Description', + assignee_id: 'user1', + priority: 1, + label_ids: ['bug'] + } + end + + context 'when the API response is success' do + before do + stub_request(:post, url) + .to_return(status: 200, body: { success: true, data: { issueCreate: { id: 'issue1', title: 'Title' } } }.to_json, headers: headers) + end + + it 'creates an issue' do + response = linear_client.create_issue(params) + expect(response).to eq({ 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } }) + end + + context 'when the priority is invalid' do + let(:params) { { title: 'Title', team_id: 'team1', priority: 5 } } + + it 'raises an exception' do + expect { linear_client.create_issue(params) }.to raise_error(ArgumentError, 'Invalid priority value. Priority must be 0, 1, 2, 3, or 4.') + end + end + + context 'when the label_ids are invalid' do + let(:params) { { title: 'Title', team_id: 'team1', label_ids: 'bug' } } + + it 'raises an exception' do + expect { linear_client.create_issue(params) }.to raise_error(ArgumentError, 'label_ids must be an array of strings.') + end + end + + context 'when the title is missing' do + let(:params) { { team_id: 'team1' } } + + it 'raises an exception' do + expect { linear_client.create_issue(params) }.to raise_error(ArgumentError, 'Missing title') + end + end + + context 'when the team_id is missing' do + let(:params) { { title: 'Title' } } + + it 'raises an exception' do + expect { linear_client.create_issue(params) }.to raise_error(ArgumentError, 'Missing team id') + end + end + + context 'when the API key is invalid' do + before do + stub_request(:post, url) + .to_return(status: 401, body: { errors: [{ message: 'Invalid API key' }] }.to_json, headers: headers) + end + + it 'raises an exception' do + response = linear_client.create_issue(params) + expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Invalid API key' }] }, :error_code => 401 }) + end + end + end + + context 'when the API response is an error' do + before do + stub_request(:post, url) + .to_return(status: 422, body: { errors: [{ message: 'Error creating issue' }] }.to_json, headers: headers) + end + + it 'raises an exception' do + response = linear_client.create_issue(params) + expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error creating issue' }] }, :error_code => 422 }) + end + end + end + + context 'when linking an issue' do + let(:link) { 'https://example.com' } + let(:issue_id) { 'issue1' } + let(:title) { 'Title' } + + context 'when the API response is success' do + before do + stub_request(:post, url) + .to_return(status: 200, body: { success: true, data: { attachmentLinkURL: { id: 'attachment1' } } }.to_json, headers: headers) + end + + it 'links an issue' do + response = linear_client.link_issue(link, issue_id, title) + expect(response).to eq({ 'attachmentLinkURL' => { 'id' => 'attachment1' } }) + end + + context 'when the link is missing' do + let(:link) { '' } + + it 'raises an exception' do + expect { linear_client.link_issue(link, issue_id, title) }.to raise_error(ArgumentError, 'Missing link') + end + end + + context 'when the issue_id is missing' do + let(:issue_id) { '' } + + it 'raises an exception' do + expect { linear_client.link_issue(link, issue_id, title) }.to raise_error(ArgumentError, 'Missing issue id') + end + end + end + + context 'when the API response is an error' do + before do + stub_request(:post, url) + .to_return(status: 422, body: { errors: [{ message: 'Error linking issue' }] }.to_json, headers: headers) + end + + it 'raises an exception' do + response = linear_client.link_issue(link, issue_id, title) + expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error linking issue' }] }, :error_code => 422 }) + end + end + end + + context 'when unlinking an issue' do + let(:link_id) { 'attachment1' } + + context 'when the API response is success' do + before do + stub_request(:post, url) + .to_return(status: 200, body: { success: true, data: { attachmentLinkURL: { id: 'attachment1' } } }.to_json, headers: headers) + end + + it 'unlinks an issue' do + response = linear_client.unlink_issue(link_id) + expect(response).to eq({ 'attachmentLinkURL' => { 'id' => 'attachment1' } }) + end + + context 'when the link_id is missing' do + let(:link_id) { '' } + + it 'raises an exception' do + expect { linear_client.unlink_issue(link_id) }.to raise_error(ArgumentError, 'Missing link id') + end + end + end + + context 'when the API response is an error' do + before do + stub_request(:post, url) + .to_return(status: 422, body: { errors: [{ message: 'Error unlinking issue' }] }.to_json, headers: headers) + end + + it 'raises an exception' do + response = linear_client.unlink_issue(link_id) + expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error unlinking issue' }] }, :error_code => 422 }) + end + end + end + + context 'when querying issues' do + let(:term) { 'term' } + + context 'when the API response is success' do + before do + stub_request(:post, url) + .to_return(status: 200, body: { success: true, + data: { searchIssues: { nodes: [{ id: 'issue1', title: 'Title' }] } } }.to_json, headers: headers) + end + + it 'returns issues' do + response = linear_client.search_issue(term) + expect(response).to eq({ 'searchIssues' => { 'nodes' => [{ 'id' => 'issue1', 'title' => 'Title' }] } }) + end + end + + context 'when the API response is an error' do + before do + stub_request(:post, url) + .to_return(status: 422, body: { errors: [{ message: 'Error retrieving data' }] }.to_json, + headers: headers) + end + + it 'raises an exception' do + response = linear_client.search_issue(term) + expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error retrieving data' }] }, :error_code => 422 }) + end + end + end + + context 'when querying linked issues' do + context 'when the API response is success' do + before do + stub_request(:post, url) + .to_return(status: 200, body: { success: true, data: { linkedIssue: { id: 'issue1', title: 'Title' } } }.to_json, headers: headers) + end + + it 'returns linked issues' do + response = linear_client.linked_issues('app.chatwoot.com') + expect(response).to eq({ 'linkedIssue' => { 'id' => 'issue1', 'title' => 'Title' } }) + end + end + + context 'when the API response is an error' do + before do + stub_request(:post, url) + .to_return(status: 422, body: { errors: [{ message: 'Error retrieving data' }] }.to_json, + headers: headers) + end + + it 'raises an exception' do + response = linear_client.linked_issues('app.chatwoot.com') + expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error retrieving data' }] }, :error_code => 422 }) + end + end + end +end From 87d92f73d49c12f51b1aaf772683059341dd928f Mon Sep 17 00:00:00 2001 From: Pranav Date: Wed, 22 May 2024 17:34:24 -0700 Subject: [PATCH 34/52] feat: Improve Report API performance (#9476) - Re-write the methods for clarity - Remove the dependency on the ReportHelper class. - Remove n+1 queries in the average metric time series data. --- .../conversations/base_report_builder.rb | 30 +++ .../reports/conversations/metric_builder.rb | 30 +++ .../reports/conversations/report_builder.rb | 21 +++ .../timeseries/average_report_builder.rb | 48 +++++ .../timeseries/base_timeseries_builder.rb | 46 +++++ .../timeseries/count_report_builder.rb | 71 +++++++ .../api/v2/accounts/reports_controller.rb | 19 +- app/helpers/timezone_helper.rb | 19 ++ .../conversations/metric_builder_spec.rb | 50 +++++ .../conversations/report_builder_spec.rb | 44 +++++ .../timeseries/average_report_builder_spec.rb | 174 ++++++++++++++++++ .../api/v2/accounts/report_controller_spec.rb | 4 +- spec/factories/reporting_events.rb | 1 + 13 files changed, 545 insertions(+), 12 deletions(-) create mode 100644 app/builders/v2/reports/conversations/base_report_builder.rb create mode 100644 app/builders/v2/reports/conversations/metric_builder.rb create mode 100644 app/builders/v2/reports/conversations/report_builder.rb create mode 100644 app/builders/v2/reports/timeseries/average_report_builder.rb create mode 100644 app/builders/v2/reports/timeseries/base_timeseries_builder.rb create mode 100644 app/builders/v2/reports/timeseries/count_report_builder.rb create mode 100644 app/helpers/timezone_helper.rb create mode 100644 spec/builders/v2/reports/conversations/metric_builder_spec.rb create mode 100644 spec/builders/v2/reports/conversations/report_builder_spec.rb create mode 100644 spec/builders/v2/reports/timeseries/average_report_builder_spec.rb diff --git a/app/builders/v2/reports/conversations/base_report_builder.rb b/app/builders/v2/reports/conversations/base_report_builder.rb new file mode 100644 index 000000000..a7961b0d6 --- /dev/null +++ b/app/builders/v2/reports/conversations/base_report_builder.rb @@ -0,0 +1,30 @@ +class V2::Reports::Conversations::BaseReportBuilder + pattr_initialize :account, :params + + private + + AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze + COUNT_METRICS = %w[ + conversations_count + incoming_messages_count + outgoing_messages_count + resolutions_count + bot_resolutions_count + bot_handoffs_count + ].freeze + + def builder_class(metric) + case metric + when *AVG_METRICS + V2::Reports::Timeseries::AverageReportBuilder + when *COUNT_METRICS + V2::Reports::Timeseries::CountReportBuilder + end + end + + def log_invalid_metric + Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}" + + {} + end +end diff --git a/app/builders/v2/reports/conversations/metric_builder.rb b/app/builders/v2/reports/conversations/metric_builder.rb new file mode 100644 index 000000000..6635fb186 --- /dev/null +++ b/app/builders/v2/reports/conversations/metric_builder.rb @@ -0,0 +1,30 @@ +class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder + def summary + { + conversations_count: count('conversations_count'), + incoming_messages_count: count('incoming_messages_count'), + outgoing_messages_count: count('outgoing_messages_count'), + avg_first_response_time: count('avg_first_response_time'), + avg_resolution_time: count('avg_resolution_time'), + resolutions_count: count('resolutions_count'), + reply_time: count('reply_time') + } + end + + def bot_summary + { + bot_resolutions_count: count('bot_resolutions_count'), + bot_handoffs_count: count('bot_handoffs_count') + } + end + + private + + def count(metric) + builder_class(metric).new(account, builder_params(metric)).aggregate_value + end + + def builder_params(metric) + params.merge({ metric: metric }) + end +end diff --git a/app/builders/v2/reports/conversations/report_builder.rb b/app/builders/v2/reports/conversations/report_builder.rb new file mode 100644 index 000000000..8f992d4c6 --- /dev/null +++ b/app/builders/v2/reports/conversations/report_builder.rb @@ -0,0 +1,21 @@ +class V2::Reports::Conversations::ReportBuilder < V2::Reports::Conversations::BaseReportBuilder + def timeseries + perform_action(:timeseries) + end + + def aggregate_value + perform_action(:aggregate_value) + end + + private + + def perform_action(method_name) + return builder.new(account, params).public_send(method_name) if builder.present? + + log_invalid_metric + end + + def builder + builder_class(params[:metric]) + end +end diff --git a/app/builders/v2/reports/timeseries/average_report_builder.rb b/app/builders/v2/reports/timeseries/average_report_builder.rb new file mode 100644 index 000000000..3e30557e1 --- /dev/null +++ b/app/builders/v2/reports/timeseries/average_report_builder.rb @@ -0,0 +1,48 @@ +class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder + def timeseries + grouped_average_time = reporting_events.average(average_value_key) + grouped_event_count = reporting_events.count + grouped_average_time.each_with_object([]) do |element, arr| + event_date, average_time = element + arr << { + value: average_time, + timestamp: event_date.in_time_zone(timezone).to_i, + count: grouped_event_count[event_date] + } + end + end + + def aggregate_value + object_scope.average(average_value_key) + end + + private + + def event_name + metric_to_event_name = { + avg_first_response_time: :first_response, + avg_resolution_time: :conversation_resolved, + reply_time: :reply_time + } + metric_to_event_name[params[:metric].to_sym] + end + + def object_scope + scope.reporting_events.where(name: event_name, created_at: range) + end + + def reporting_events + @grouped_values = object_scope.group_by_period( + group_by, + :created_at, + default_value: 0, + range: range, + permit: %w[day week month year hour], + time_zone: timezone + ) + end + + def average_value_key + @average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value + end +end diff --git a/app/builders/v2/reports/timeseries/base_timeseries_builder.rb b/app/builders/v2/reports/timeseries/base_timeseries_builder.rb new file mode 100644 index 000000000..50699417d --- /dev/null +++ b/app/builders/v2/reports/timeseries/base_timeseries_builder.rb @@ -0,0 +1,46 @@ +class V2::Reports::Timeseries::BaseTimeseriesBuilder + include TimezoneHelper + include DateRangeHelper + DEFAULT_GROUP_BY = 'day'.freeze + + pattr_initialize :account, :params + + def scope + case params[:type].to_sym + when :account + account + when :inbox + inbox + when :agent + user + when :label + label + when :team + team + end + end + + def inbox + @inbox ||= account.inboxes.find(params[:id]) + end + + def user + @user ||= account.users.find(params[:id]) + end + + def label + @label ||= account.labels.find(params[:id]) + end + + def team + @team ||= account.teams.find(params[:id]) + end + + def group_by + @group_by ||= %w[day week month year hour].include?(params[:group_by]) ? params[:group_by] : DEFAULT_GROUP_BY + end + + def timezone + @timezone ||= timezone_name_from_offset(params[:timezone_offset]) + end +end diff --git a/app/builders/v2/reports/timeseries/count_report_builder.rb b/app/builders/v2/reports/timeseries/count_report_builder.rb new file mode 100644 index 000000000..03a87a6fa --- /dev/null +++ b/app/builders/v2/reports/timeseries/count_report_builder.rb @@ -0,0 +1,71 @@ +class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder + def timeseries + grouped_count.each_with_object([]) do |element, arr| + event_date, event_count = element + + # The `event_date` is in Date format (without time), such as "Wed, 15 May 2024". + # We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i` + # because it converts the date to 12:00 AM server timezone. + # The desired output should be 12:00 AM in the specified timezone. + arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i } + end + end + + def aggregate_value + object_scope.count + end + + private + + def metric + @metric ||= params[:metric] + end + + def object_scope + send("scope_for_#{metric}") + end + + def scope_for_conversations_count + scope.conversations.where(account_id: account.id, created_at: range) + end + + def scope_for_incoming_messages_count + scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order) + end + + def scope_for_outgoing_messages_count + scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order) + end + + def scope_for_resolutions_count + scope.reporting_events.joins(:conversation).select(:conversation_id).where( + name: :conversation_resolved, + conversations: { status: :resolved }, created_at: range + ).distinct + end + + def scope_for_bot_resolutions_count + scope.reporting_events.joins(:conversation).select(:conversation_id).where( + name: :conversation_bot_resolved, + conversations: { status: :resolved }, created_at: range + ).distinct + end + + def scope_for_bot_handoffs_count + scope.reporting_events.joins(:conversation).select(:conversation_id).where( + name: :conversation_bot_handoff, + created_at: range + ).distinct + end + + def grouped_count + @grouped_values = object_scope.group_by_period( + group_by, + :created_at, + default_value: 0, + range: range, + permit: %w[day week month year hour], + time_zone: timezone + ).count + end +end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index c67b74a43..ed5be5518 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -5,19 +5,17 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController before_action :check_authorization def index - builder = V2::ReportBuilder.new(Current.account, report_params) - data = builder.build + builder = V2::Reports::Conversations::ReportBuilder.new(Current.account, report_params) + data = builder.timeseries render json: data end def summary - render json: summary_metrics + render json: build_summary(:summary) end def bot_summary - summary = V2::ReportBuilder.new(Current.account, current_summary_params).bot_summary - summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).bot_summary - render json: summary + render json: build_summary(:bot_summary) end def agents @@ -126,10 +124,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController } end - def summary_metrics - summary = V2::ReportBuilder.new(Current.account, current_summary_params).summary - summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary - summary + def build_summary(method) + builder = V2::Reports::Conversations::MetricBuilder + current_summary = builder.new(Current.account, current_summary_params).send(method) + previous_summary = builder.new(Current.account, previous_summary_params).send(method) + current_summary.merge(previous: previous_summary) end def conversation_metrics diff --git a/app/helpers/timezone_helper.rb b/app/helpers/timezone_helper.rb new file mode 100644 index 000000000..b016cc9d9 --- /dev/null +++ b/app/helpers/timezone_helper.rb @@ -0,0 +1,19 @@ +module TimezoneHelper + # ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset] + # would return the timezone without considering day light savings. To get the correct timezone, + # this method uses zone.now.utc_offset for comparison as referenced in the issues below + # + # https://github.com/rails/rails/pull/22243 + # https://github.com/rails/rails/issues/21501 + # https://github.com/rails/rails/issues/7297 + def timezone_name_from_offset(offset) + return 'UTC' if offset.blank? + + offset_in_seconds = offset.to_f * 3600 + matching_zone = ActiveSupport::TimeZone.all.find do |zone| + zone.now.utc_offset == offset_in_seconds + end + + return matching_zone.name if matching_zone + end +end diff --git a/spec/builders/v2/reports/conversations/metric_builder_spec.rb b/spec/builders/v2/reports/conversations/metric_builder_spec.rb new file mode 100644 index 000000000..1b0ed7a38 --- /dev/null +++ b/spec/builders/v2/reports/conversations/metric_builder_spec.rb @@ -0,0 +1,50 @@ +require 'rails_helper' + +RSpec.describe V2::Reports::Conversations::MetricBuilder, type: :model do + subject { described_class.new(account, params) } + + let(:account) { create(:account) } + let(:params) { { since: '2023-01-01', until: '2024-01-01' } } + let(:count_builder_instance) { instance_double(V2::Reports::Timeseries::CountReportBuilder, aggregate_value: 42) } + let(:avg_builder_instance) { instance_double(V2::Reports::Timeseries::AverageReportBuilder, aggregate_value: 42) } + + before do + allow(V2::Reports::Timeseries::CountReportBuilder).to receive(:new).and_return(count_builder_instance) + allow(V2::Reports::Timeseries::AverageReportBuilder).to receive(:new).and_return(avg_builder_instance) + end + + describe '#summary' do + it 'returns the correct summary values' do + summary = subject.summary + expect(summary).to eq( + { + conversations_count: 42, + incoming_messages_count: 42, + outgoing_messages_count: 42, + avg_first_response_time: 42, + avg_resolution_time: 42, + resolutions_count: 42, + reply_time: 42 + } + ) + end + + it 'creates builders with proper params' do + subject.summary + expect(V2::Reports::Timeseries::CountReportBuilder).to have_received(:new).with(account, params.merge(metric: 'conversations_count')) + expect(V2::Reports::Timeseries::AverageReportBuilder).to have_received(:new).with(account, params.merge(metric: 'avg_first_response_time')) + end + end + + describe '#bot_summary' do + it 'returns a detailed summary of bot-specific conversation metrics' do + bot_summary = subject.bot_summary + expect(bot_summary).to eq( + { + bot_resolutions_count: 42, + bot_handoffs_count: 42 + } + ) + end + end +end diff --git a/spec/builders/v2/reports/conversations/report_builder_spec.rb b/spec/builders/v2/reports/conversations/report_builder_spec.rb new file mode 100644 index 000000000..d3cde98e6 --- /dev/null +++ b/spec/builders/v2/reports/conversations/report_builder_spec.rb @@ -0,0 +1,44 @@ +require 'rails_helper' + +describe V2::Reports::Conversations::ReportBuilder do + subject { described_class.new(account, params) } + + let(:account) { create(:account) } + let(:average_builder) { V2::Reports::Timeseries::AverageReportBuilder } + let(:count_builder) { V2::Reports::Timeseries::CountReportBuilder } + + shared_examples 'valid metric handler' do |metric, method, builder| + context 'when a valid metric is given' do + let(:params) { { metric: metric } } + + it "calls the correct #{method} builder for #{metric}" do + builder_instance = instance_double(builder) + allow(builder).to receive(:new).and_return(builder_instance) + allow(builder_instance).to receive(method) + + builder_instance.public_send(method) + expect(builder_instance).to have_received(method) + end + end + end + + context 'when invalid metric is given' do + let(:metric) { 'invalid_metric' } + let(:params) { { metric: metric } } + + it 'logs the error and returns empty value' do + expect(Rails.logger).to receive(:error).with("ReportBuilder: Invalid metric - #{metric}") + expect(subject.timeseries).to eq({}) + end + end + + describe '#timeseries' do + include_examples 'valid metric handler', 'avg_first_response_time', :timeseries, V2::Reports::Timeseries::AverageReportBuilder + include_examples 'valid metric handler', 'conversations_count', :timeseries, V2::Reports::Timeseries::CountReportBuilder + end + + describe '#aggregate_value' do + include_examples 'valid metric handler', 'avg_first_response_time', :aggregate_value, V2::Reports::Timeseries::AverageReportBuilder + include_examples 'valid metric handler', 'conversations_count', :aggregate_value, V2::Reports::Timeseries::CountReportBuilder + end +end diff --git a/spec/builders/v2/reports/timeseries/average_report_builder_spec.rb b/spec/builders/v2/reports/timeseries/average_report_builder_spec.rb new file mode 100644 index 000000000..4f6036f07 --- /dev/null +++ b/spec/builders/v2/reports/timeseries/average_report_builder_spec.rb @@ -0,0 +1,174 @@ +require 'rails_helper' + +describe V2::Reports::Timeseries::AverageReportBuilder do + subject { described_class.new(account, params) } + + let(:account) { create(:account) } + let(:team) { create(:team, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:label) { create(:label, title: 'spec-billing', account: account) } + let!(:conversation) { create(:conversation, account: account, inbox: inbox, team: team) } + let(:current_time) { '26.10.2020 10:00'.to_datetime } + + let(:params) do + { + type: filter_type, + business_hours: business_hours, + timezone_offset: timezone_offset, + group_by: group_by, + metric: metric, + since: (current_time - 1.week).beginning_of_day.to_i.to_s, + until: current_time.end_of_day.to_i.to_s, + id: filter_id + } + end + let(:timezone_offset) { nil } + let(:group_by) { 'day' } + let(:metric) { 'avg_first_response_time' } + let(:business_hours) { false } + let(:filter_type) { :account } + let(:filter_id) { '' } + + before do + travel_to current_time + conversation.label_list.add(label.title) + conversation.save! + create(:reporting_event, name: 'first_response', value: 80, value_in_business_hours: 10, account: account, created_at: Time.zone.now, + conversation: conversation, inbox: inbox) + create(:reporting_event, name: 'first_response', value: 100, value_in_business_hours: 20, account: account, created_at: 1.hour.ago) + create(:reporting_event, name: 'first_response', value: 93, value_in_business_hours: 30, account: account, created_at: 1.week.ago) + end + + describe '#timeseries' do + context 'when there is no filter applied' do + it 'returns the correct values' do + timeseries_values = subject.timeseries + + expect(timeseries_values).to eq( + [ + { count: 1, timestamp: 1_603_065_600, value: 93.0 }, + { count: 0, timestamp: 1_603_152_000, value: 0 }, + { count: 0, timestamp: 1_603_238_400, value: 0 }, + { count: 0, timestamp: 1_603_324_800, value: 0 }, + { count: 0, timestamp: 1_603_411_200, value: 0 }, + { count: 0, timestamp: 1_603_497_600, value: 0 }, + { count: 0, timestamp: 1_603_584_000, value: 0 }, + { count: 2, timestamp: 1_603_670_400, value: 90.0 } + ] + ) + end + + context 'when business hours is provided' do + let(:business_hours) { true } + + it 'returns correct timeseries' do + timeseries_values = subject.timeseries + + expect(timeseries_values).to eq( + [ + { count: 1, timestamp: 1_603_065_600, value: 30.0 }, + { count: 0, timestamp: 1_603_152_000, value: 0 }, + { count: 0, timestamp: 1_603_238_400, value: 0 }, + { count: 0, timestamp: 1_603_324_800, value: 0 }, + { count: 0, timestamp: 1_603_411_200, value: 0 }, + { count: 0, timestamp: 1_603_497_600, value: 0 }, + { count: 0, timestamp: 1_603_584_000, value: 0 }, + { count: 2, timestamp: 1_603_670_400, value: 15.0 } + ] + ) + end + end + + context 'when group_by is provided' do + let(:group_by) { 'week' } + + it 'returns correct timeseries' do + timeseries_values = subject.timeseries + expect(timeseries_values).to eq( + [ + { count: 1, timestamp: (current_time - 1.week).beginning_of_week(:sunday).to_i, value: 93.0 }, + { count: 2, timestamp: current_time.beginning_of_week(:sunday).to_i, value: 90.0 } + ] + ) + end + end + + context 'when timezone offset is provided' do + let(:timezone_offset) { '5.5' } + let(:group_by) { 'week' } + + it 'returns correct timeseries' do + timeseries_values = subject.timeseries + expect(timeseries_values).to eq( + [ + { count: 1, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 93.0 }, + { count: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 90.0 } + ] + ) + end + end + end + + context 'when the label filter is applied' do + let(:group_by) { 'week' } + let(:filter_type) { 'label' } + let(:filter_id) { label.id } + + it 'returns correct timeseries' do + timeseries_values = subject.timeseries + start_of_the_week = current_time.beginning_of_week(:sunday).to_i + last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i + expect(timeseries_values).to eq( + [ + { count: 0, timestamp: last_week_start_of_the_week, value: 0 }, + { count: 1, timestamp: start_of_the_week, value: 80.0 } + ] + ) + end + end + + context 'when the inbox filter is applied' do + let(:group_by) { 'week' } + let(:filter_type) { 'inbox' } + let(:filter_id) { inbox.id } + + it 'returns correct timeseries' do + timeseries_values = subject.timeseries + start_of_the_week = current_time.beginning_of_week(:sunday).to_i + last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i + expect(timeseries_values).to eq( + [ + { count: 0, timestamp: last_week_start_of_the_week, value: 0 }, + { count: 1, timestamp: start_of_the_week, value: 80.0 } + ] + ) + end + end + + context 'when the team filter is applied' do + let(:group_by) { 'week' } + let(:filter_type) { 'team' } + let(:filter_id) { team.id } + + it 'returns correct timeseries' do + timeseries_values = subject.timeseries + start_of_the_week = current_time.beginning_of_week(:sunday).to_i + last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i + expect(timeseries_values).to eq( + [ + { count: 0, timestamp: last_week_start_of_the_week, value: 0 }, + { count: 1, timestamp: start_of_the_week, value: 80.0 } + ] + ) + end + end + end + + describe '#aggregate_value' do + context 'when there is no filter applied' do + it 'returns the correct average value' do + expect(subject.aggregate_value).to eq 91.0 + end + end + end +end diff --git a/spec/controllers/api/v2/accounts/report_controller_spec.rb b/spec/controllers/api/v2/accounts/report_controller_spec.rb index fc0228c86..6202946a1 100644 --- a/spec/controllers/api/v2/accounts/report_controller_spec.rb +++ b/spec/controllers/api/v2/accounts/report_controller_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'Reports API', type: :request do let!(:user) { create(:user, account: account) } let!(:inbox) { create(:inbox, account: account) } let(:inbox_member) { create(:inbox_member, user: user, inbox: inbox) } - let(:default_timezone) { ActiveSupport::TimeZone[0]&.name } + let(:default_timezone) { 'UTC' } let(:start_of_today) { Time.current.in_time_zone(default_timezone).beginning_of_day.to_i } let(:end_of_today) { Time.current.in_time_zone(default_timezone).end_of_day.to_i } let(:params) { { timezone_offset: Time.zone.utc_offset } } @@ -18,7 +18,7 @@ RSpec.describe 'Reports API', type: :request do assignee: user, created_at: Time.current.in_time_zone(default_timezone).to_date) end - describe 'GET /api/v2/accounts/:account_id/reports/account' do + describe 'GET /api/v2/accounts/:account_id/reports' do context 'when it is an unauthenticated user' do it 'returns unauthorized' do get "/api/v2/accounts/#{account.id}/reports" diff --git a/spec/factories/reporting_events.rb b/spec/factories/reporting_events.rb index c779c01f6..1db6a87df 100644 --- a/spec/factories/reporting_events.rb +++ b/spec/factories/reporting_events.rb @@ -2,6 +2,7 @@ FactoryBot.define do factory :reporting_event do name { 'MyString' } value { 1.5 } + value_in_business_hours { 1 } account_id { 1 } inbox_id { 1 } user_id { 1 } From 4b93738462dd3f61f0fe3edbe04ca011d0bf2983 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 23 May 2024 10:40:44 +0530 Subject: [PATCH 35/52] fix: Space key in input closing dropdown (#9525) --- .../dashboard/components/ui/Dropdown/DropdownSearch.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/javascript/dashboard/components/ui/Dropdown/DropdownSearch.vue b/app/javascript/dashboard/components/ui/Dropdown/DropdownSearch.vue index 558816deb..c153a82a7 100644 --- a/app/javascript/dashboard/components/ui/Dropdown/DropdownSearch.vue +++ b/app/javascript/dashboard/components/ui/Dropdown/DropdownSearch.vue @@ -18,11 +18,11 @@ defineProps({
-
+
Date: Thu, 23 May 2024 11:22:14 +0530 Subject: [PATCH 36/52] fix: TypeError cannot read properties of undefined (reading 'status') (#9505) --- app/javascript/dashboard/components/ChatList.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 60966b476..5b592a1f7 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -987,7 +987,7 @@ export default { allSelectedConversationsStatus(status) { if (!this.selectedConversations.length) return false; return this.selectedConversations.every(item => { - return this.$store.getters.getConversationById(item).status === status; + return this.$store.getters.getConversationById(item)?.status === status; }); }, onContextMenuToggle(state) { From 35508feaae4808b730c908687fa9e16b9cba3a91 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 23 May 2024 11:58:24 +0530 Subject: [PATCH 37/52] feat: Linear front end (#9491) Co-authored-by: Shivam Mishra Co-authored-by: iamsivin --- .../integrations/linear_controller.rb | 2 +- .../dashboard/api/integrations/linear.js | 46 ++++ .../api/specs/integrations/linear.spec.js | 202 +++++++++++++++ .../components/buttons/ResolveAction.vue | 2 +- .../components/ui/Dropdown/DropdownList.vue | 7 +- .../conversation/ConversationHeader.vue | 21 ++ .../conversation/linear/CreateIssue.vue | 232 ++++++++++++++++++ .../conversation/linear/CreateOrLinkIssue.vue | 79 ++++++ .../widgets/conversation/linear/Issue.vue | 123 ++++++++++ .../conversation/linear/IssueHeader.vue | 56 +++++ .../widgets/conversation/linear/LinkIssue.vue | 127 ++++++++++ .../widgets/conversation/linear/index.vue | 117 +++++++++ .../conversation/linear/validations.js | 10 + app/javascript/dashboard/featureFlags.js | 1 + .../i18n/locale/en/integrations.json | 68 +++++ .../dashboard/conversation/ContactPanel.vue | 1 - .../FluentIcon/dashboard-icons.json | 5 +- lib/linear.rb | 3 +- 18 files changed, 1095 insertions(+), 7 deletions(-) create mode 100644 app/javascript/dashboard/api/integrations/linear.js create mode 100644 app/javascript/dashboard/api/specs/integrations/linear.spec.js create mode 100644 app/javascript/dashboard/components/widgets/conversation/linear/CreateIssue.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/linear/CreateOrLinkIssue.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/linear/Issue.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/linear/IssueHeader.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/linear/LinkIssue.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/linear/index.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/linear/validations.js diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index 9d5d76d75..814373c7e 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -88,6 +88,6 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas end def permitted_params - params.permit(:team_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, label_ids: []) end end diff --git a/app/javascript/dashboard/api/integrations/linear.js b/app/javascript/dashboard/api/integrations/linear.js new file mode 100644 index 000000000..600f169d6 --- /dev/null +++ b/app/javascript/dashboard/api/integrations/linear.js @@ -0,0 +1,46 @@ +/* global axios */ + +import ApiClient from '../ApiClient'; + +class LinearAPI extends ApiClient { + constructor() { + super('integrations/linear', { accountScoped: true }); + } + + getTeams() { + return axios.get(`${this.url}/teams`); + } + + getTeamEntities(teamId) { + return axios.get(`${this.url}/team_entities?team_id=${teamId}`); + } + + createIssue(data) { + return axios.post(`${this.url}/create_issue`, data); + } + + link_issue(conversationId, issueId) { + return axios.post(`${this.url}/link_issue`, { + issue_id: issueId, + conversation_id: conversationId, + }); + } + + getLinkedIssue(conversationId) { + return axios.get( + `${this.url}/linked_issues?conversation_id=${conversationId}` + ); + } + + unlinkIssue(linkId) { + return axios.post(`${this.url}/unlink_issue`, { + link_id: linkId, + }); + } + + searchIssues(query) { + return axios.get(`${this.url}/search_issue?q=${query}`); + } +} + +export default new LinearAPI(); diff --git a/app/javascript/dashboard/api/specs/integrations/linear.spec.js b/app/javascript/dashboard/api/specs/integrations/linear.spec.js new file mode 100644 index 000000000..cc16feb16 --- /dev/null +++ b/app/javascript/dashboard/api/specs/integrations/linear.spec.js @@ -0,0 +1,202 @@ +import LinearAPIClient from '../../integrations/linear'; +import ApiClient from '../../ApiClient'; + +describe('#linearAPI', () => { + it('creates correct instance', () => { + expect(LinearAPIClient).toBeInstanceOf(ApiClient); + expect(LinearAPIClient).toHaveProperty('getTeams'); + expect(LinearAPIClient).toHaveProperty('getTeamEntities'); + expect(LinearAPIClient).toHaveProperty('createIssue'); + expect(LinearAPIClient).toHaveProperty('link_issue'); + expect(LinearAPIClient).toHaveProperty('getLinkedIssue'); + expect(LinearAPIClient).toHaveProperty('unlinkIssue'); + expect(LinearAPIClient).toHaveProperty('searchIssues'); + }); + + describe('getTeams', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.getTeams(); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/teams' + ); + }); + }); + + describe('getTeamEntities', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.getTeamEntities(1); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/team_entities?team_id=1' + ); + }); + }); + + describe('createIssue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + const issueData = { + title: 'New Issue', + description: 'Issue description', + }; + LinearAPIClient.createIssue(issueData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/create_issue', + issueData + ); + }); + }); + + describe('link_issue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.link_issue(1, 2); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/link_issue', + { + issue_id: 2, + conversation_id: 1, + } + ); + }); + }); + + describe('getLinkedIssue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.getLinkedIssue(1); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/linked_issues?conversation_id=1' + ); + }); + }); + + describe('unlinkIssue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.unlinkIssue(1); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/unlink_issue', + { + link_id: 1, + } + ); + }); + }); + + describe('searchIssues', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.searchIssues('query'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/search_issue?q=query' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/components/buttons/ResolveAction.vue b/app/javascript/dashboard/components/buttons/ResolveAction.vue index c56f27e14..91423c762 100644 --- a/app/javascript/dashboard/components/buttons/ResolveAction.vue +++ b/app/javascript/dashboard/components/buttons/ResolveAction.vue @@ -1,5 +1,5 @@