diff --git a/Gemfile.lock b/Gemfile.lock index d9908f5e1..8315a5374 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -172,6 +172,8 @@ GEM bundler (>= 1.2.0, < 3) thor (~> 1.0) byebug (11.1.3) + childprocess (5.1.0) + logger (~> 1.5) climate_control (1.2.0) coderay (1.1.3) commonmarker (0.23.10) @@ -433,10 +435,12 @@ GEM json (>= 1.8) rexml language_server-protocol (3.17.0.5) - launchy (2.5.2) + launchy (3.1.1) addressable (~> 2.8) - letter_opener (1.8.1) - launchy (>= 2.2, < 3) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) line-bot-api (1.28.0) lint_roller (1.1.0) liquid (5.4.0) @@ -563,7 +567,7 @@ GEM method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (6.0.0) + public_suffix (6.0.2) puma (6.4.3) nio4r (~> 2.0) pundit (2.3.0) diff --git a/app/assets/javascripts/secretField.js b/app/assets/javascripts/secretField.js index 463109812..da2327eff 100644 --- a/app/assets/javascripts/secretField.js +++ b/app/assets/javascripts/secretField.js @@ -10,7 +10,8 @@ function toggleSecretField(e) { if (!textElement) return; if (textElement.dataset.secretMasked === 'false') { - textElement.textContent = '•'.repeat(10); + const maskedLength = secretField.dataset.secretText?.length || 10; + textElement.textContent = '•'.repeat(maskedLength); textElement.dataset.secretMasked = 'true'; toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show'); @@ -32,3 +33,13 @@ function copySecretField(e) { navigator.clipboard.writeText(secretField.dataset.secretText); } + +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.cell-data__secret-field').forEach(field => { + const span = field.querySelector('[data-secret-masked]'); + if (span && span.dataset.secretMasked === 'true') { + const len = field.dataset.secretText?.length || 10; + span.textContent = '•'.repeat(len); + } + }); +}); diff --git a/app/assets/stylesheets/administrate/components/_cells.scss b/app/assets/stylesheets/administrate/components/_cells.scss index b5a079976..ae2d603cd 100644 --- a/app/assets/stylesheets/administrate/components/_cells.scss +++ b/app/assets/stylesheets/administrate/components/_cells.scss @@ -46,17 +46,25 @@ .cell-data__secret-field { align-items: center; + color: $hint-grey; display: flex; span { - flex: 1; + flex: 0 0 auto; } - button { - margin-left: 5px; + [data-secret-toggler], + [data-secret-copier] { + background: transparent; + border: 0; + color: inherit; + margin-left: 0.5rem; + padding: 0; svg { fill: currentColor; + height: 1.25rem; + width: 1.25rem; } } } diff --git a/app/builders/v2/reports/label_summary_builder.rb b/app/builders/v2/reports/label_summary_builder.rb index abc68b26b..caa5a04d8 100644 --- a/app/builders/v2/reports/label_summary_builder.rb +++ b/app/builders/v2/reports/label_summary_builder.rb @@ -31,7 +31,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder resolved_counts: fetch_resolved_counts(conversation_filter), resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours), first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours), - reply_metrics: fetch_metrics(conversation_filter, 'reply', use_business_hours) + reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours) } end @@ -63,7 +63,9 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder end def fetch_resolved_counts(conversation_filter) - fetch_counts(conversation_filter.merge(status: :resolved)) + # since the base query is ActsAsTaggableOn, + # the status :resolved won't automatically be converted to integer status + fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved])) end def fetch_counts(conversation_filter) diff --git a/app/controllers/api/v1/accounts/google/authorizations_controller.rb b/app/controllers/api/v1/accounts/google/authorizations_controller.rb index 1140a214b..87a7cfa3f 100644 --- a/app/controllers/api/v1/accounts/google/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/google/authorizations_controller.rb @@ -1,32 +1,23 @@ -class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController include GoogleConcern - before_action :check_authorization def create - email = params[:authorization][:email] redirect_url = google_client.auth_code.authorize_url( { redirect_uri: "#{base_url}/google/callback", - scope: 'email profile https://mail.google.com/', + scope: scope, response_type: 'code', prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it access_type: 'offline', # the default is 'online' + state: state, client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) } ) if redirect_url - cache_key = "google::#{email.downcase}" - ::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes) render json: { success: true, url: redirect_url } else render json: { success: false }, status: :unprocessable_entity end end - - private - - def check_authorization - raise Pundit::NotAuthorizedError unless Current.account_user.administrator? - end end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 61d16b2ca..e7b3b197b 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -81,11 +81,15 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def create_channel - return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type]) + return unless allowed_channel_types.include?(permitted_params[:channel][:type]) account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type)) end + def allowed_channel_types + %w[web_widget api email line telegram whatsapp sms] + end + def update_inbox_working_hours @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] end diff --git a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb index eace4411a..053c29731 100644 --- a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb @@ -1,7 +1,6 @@ -class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController include InstagramConcern include Instagram::IntegrationHelper - before_action :check_authorization def create # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization @@ -21,10 +20,4 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts render json: { success: false }, status: :unprocessable_entity end end - - private - - def check_authorization - raise Pundit::NotAuthorizedError unless Current.account_user.administrator? - end end diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index c66f06909..bfdfff058 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -1,5 +1,5 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController - before_action :fetch_conversation, only: [:link_issue, :linked_issues] + before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues] before_action :fetch_hook, only: [:destroy] def destroy @@ -31,6 +31,12 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_created, + issue_data: { id: issue[:data][:identifier] }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end @@ -42,17 +48,30 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_linked, + issue_data: { id: issue_id }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end def unlink_issue link_id = permitted_params[:link_id] + issue_id = permitted_params[:issue_id] issue = linear_processor_service.unlink_issue(link_id) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_unlinked, + issue_data: { id: issue_id }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end diff --git a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb index df563094a..a300b5f59 100644 --- a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -1,28 +1,19 @@ -class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController include MicrosoftConcern - before_action :check_authorization def create - email = params[:authorization][:email] redirect_url = microsoft_client.auth_code.authorize_url( { redirect_uri: "#{base_url}/microsoft/callback", - scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile', + scope: scope, + state: state, prompt: 'consent' } ) if redirect_url - cache_key = "microsoft::#{email.downcase}" - ::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes) render json: { success: true, url: redirect_url } else render json: { success: false }, status: :unprocessable_entity end end - - private - - def check_authorization - raise Pundit::NotAuthorizedError unless Current.account_user.administrator? - end end diff --git a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb new file mode 100644 index 000000000..feb218b59 --- /dev/null +++ b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb @@ -0,0 +1,23 @@ +class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController + before_action :check_authorization + + protected + + def scope + '' + end + + def state + Current.account.to_sgid(expires_in: 15.minutes).to_s + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end + + private + + def check_authorization + raise Pundit::NotAuthorizedError unless Current.account_user.administrator? + end +end diff --git a/app/controllers/concerns/google_concern.rb b/app/controllers/concerns/google_concern.rb index 474b14aec..13de7ced3 100644 --- a/app/controllers/concerns/google_concern.rb +++ b/app/controllers/concerns/google_concern.rb @@ -14,7 +14,7 @@ module GoogleConcern private - def base_url - ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + def scope + 'email profile https://mail.google.com/' end end diff --git a/app/controllers/concerns/microsoft_concern.rb b/app/controllers/concerns/microsoft_concern.rb index 507b9f8a3..c3e0994bd 100644 --- a/app/controllers/concerns/microsoft_concern.rb +++ b/app/controllers/concerns/microsoft_concern.rb @@ -15,7 +15,7 @@ module MicrosoftConcern private - def base_url - ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + def scope + 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email' end end diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb index 9aa73956a..be0fa5008 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -6,7 +6,6 @@ class OauthCallbackController < ApplicationController ) handle_response - ::Redis::Alfred.delete(cache_key) rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception redirect_to '/' @@ -64,10 +63,6 @@ class OauthCallbackController < ApplicationController raise NotImplementedError end - def cache_key - "#{provider_name}::#{users_data['email'].downcase}" - end - def create_channel_with_inbox ActiveRecord::Base.transaction do channel_email = Channel::Email.create!(email: users_data['email'], account: account) @@ -86,12 +81,17 @@ class OauthCallbackController < ApplicationController decoded_token[0] end - def account_id - ::Redis::Alfred.get(cache_key) + def account_from_signed_id + raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? + + account = GlobalID::Locator.locate_signed(params[:state]) + raise 'Invalid or expired state' if account.nil? + + account end def account - @account ||= Account.find(account_id) + @account ||= account_from_signed_id end # Fallback name, for when name field is missing from users_data diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index 32a147d34..02023559b 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -7,7 +7,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def index @articles = @portal.articles.published.includes(:category, :author) + + @articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present? + @articles_count = @articles.count + search_articles order_by_sort_param limit_results diff --git a/app/javascript/dashboard/api/integrations/linear.js b/app/javascript/dashboard/api/integrations/linear.js index 2ac0940aa..bb327b7e8 100644 --- a/app/javascript/dashboard/api/integrations/linear.js +++ b/app/javascript/dashboard/api/integrations/linear.js @@ -33,9 +33,11 @@ class LinearAPI extends ApiClient { ); } - unlinkIssue(linkId) { + unlinkIssue(linkId, issueIdentifier, conversationId) { return axios.post(`${this.url}/unlink_issue`, { link_id: linkId, + issue_id: issueIdentifier, + conversation_id: conversationId, }); } diff --git a/app/javascript/dashboard/api/specs/integrations/linear.spec.js b/app/javascript/dashboard/api/specs/integrations/linear.spec.js index e4bf679a6..3f33e3ed9 100644 --- a/app/javascript/dashboard/api/specs/integrations/linear.spec.js +++ b/app/javascript/dashboard/api/specs/integrations/linear.spec.js @@ -91,6 +91,19 @@ describe('#linearAPI', () => { issueData ); }); + + it('creates a valid request with conversation_id', () => { + const issueData = { + title: 'New Issue', + description: 'Issue description', + conversation_id: 123, + }; + LinearAPIClient.createIssue(issueData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/create_issue', + issueData + ); + }); }); describe('link_issue', () => { @@ -120,6 +133,18 @@ describe('#linearAPI', () => { } ); }); + + it('creates a valid request with title', () => { + LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/link_issue', + { + issue_id: 'ENG-123', + conversation_id: 1, + title: 'Sample Issue', + } + ); + }); }); describe('getLinkedIssue', () => { @@ -164,12 +189,26 @@ describe('#linearAPI', () => { window.axios = originalAxios; }); - it('creates a valid request', () => { - LinearAPIClient.unlinkIssue(1); + it('creates a valid request with link_id only', () => { + LinearAPIClient.unlinkIssue('link123'); expect(axiosMock.post).toHaveBeenCalledWith( '/api/v1/integrations/linear/unlink_issue', { - link_id: 1, + link_id: 'link123', + issue_id: undefined, + conversation_id: undefined, + } + ); + }); + + it('creates a valid request with all parameters', () => { + LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/unlink_issue', + { + link_id: 'link123', + issue_id: 'ENG-456', + conversation_id: 789, } ); }); diff --git a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss index ffcfca545..1280eb069 100644 --- a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss +++ b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss @@ -47,7 +47,7 @@ @apply max-w-full; .multiselect__option { - @apply text-sm font-normal; + @apply text-sm font-normal flex justify-between items-center; span { @apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit; @@ -58,7 +58,7 @@ } &::after { - @apply bottom-0 flex items-center justify-center text-center; + @apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight; } &.multiselect__option--highlight { @@ -74,7 +74,7 @@ } &.multiselect__option--highlight::after { - @apply bg-transparent; + @apply bg-transparent text-n-slate-12; } &.multiselect__option--selected { diff --git a/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue b/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue index f00354105..7879411c8 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue @@ -123,7 +123,7 @@ const handleDocumentableClick = () => { @mouseenter="emit('hover', true)" @mouseleave="emit('hover', false)" > -
+
diff --git a/app/javascript/dashboard/components-next/copilot/CopilotLauncher.vue b/app/javascript/dashboard/components-next/copilot/CopilotLauncher.vue index c0766f382..e21c550c0 100644 --- a/app/javascript/dashboard/components-next/copilot/CopilotLauncher.vue +++ b/app/javascript/dashboard/components-next/copilot/CopilotLauncher.vue @@ -19,6 +19,7 @@ const isConversationRoute = computed(() => { 'conversation_through_mentions', 'conversation_through_unattended', 'conversation_through_participating', + 'inbox_view_conversation', ]; return CONVERSATION_ROUTES.includes(route.name); }); diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index 9c0a24925..36dd6216e 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -13,6 +13,7 @@ export function useChannelIcon(inbox) { 'Channel::WebWidget': 'i-ri-global-fill', 'Channel::Whatsapp': 'i-ri-whatsapp-fill', 'Channel::Instagram': 'i-ri-instagram-fill', + 'Channel::Voice': 'i-ri-phone-fill', }; const providerIconMap = { diff --git a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js index df30d7138..5860e30ea 100644 --- a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js +++ b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js @@ -19,6 +19,12 @@ describe('useChannelIcon', () => { expect(icon).toBe('i-ri-whatsapp-fill'); }); + it('returns correct icon for Voice channel', () => { + const inbox = { channel_type: 'Channel::Voice' }; + const { value: icon } = useChannelIcon(inbox); + expect(icon).toBe('i-ri-phone-fill'); + }); + describe('Email channel', () => { it('returns mail icon for generic email channel', () => { const inbox = { channel_type: 'Channel::Email' }; diff --git a/app/javascript/dashboard/components-next/input/Input.vue b/app/javascript/dashboard/components-next/input/Input.vue index ea6eb0417..ed6d7a20b 100644 --- a/app/javascript/dashboard/components-next/input/Input.vue +++ b/app/javascript/dashboard/components-next/input/Input.vue @@ -1,51 +1,21 @@ diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 4f9751c4e..933e117a9 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -41,6 +41,10 @@ export default { ); } + if (key === 'voice') { + return this.enabledFeatures.channel_voice; + } + return [ 'website', 'twilio', @@ -50,6 +54,7 @@ export default { 'telegram', 'line', 'instagram', + 'voice', ].includes(key); }, }, diff --git a/app/javascript/dashboard/components/widgets/conversation/linear/CreateIssue.vue b/app/javascript/dashboard/components/widgets/conversation/linear/CreateIssue.vue index 5a276cc0f..9095b1bb8 100644 --- a/app/javascript/dashboard/components/widgets/conversation/linear/CreateIssue.vue +++ b/app/javascript/dashboard/components/widgets/conversation/linear/CreateIssue.vue @@ -183,13 +183,18 @@ const createIssue = async () => { state_id: formState.stateId || undefined, priority: formState.priority || undefined, label_ids: formState.labelId ? [formState.labelId] : undefined, + conversation_id: props.conversationId, }; try { isCreating.value = true; const response = await LinearAPI.createIssue(payload); - const { id: issueId } = response.data; - await LinearAPI.link_issue(props.conversationId, issueId, props.title); + const { identifier: issueIdentifier } = response.data; + await LinearAPI.link_issue( + props.conversationId, + issueIdentifier, + props.title + ); useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS')); useTrack(LINEAR_EVENTS.CREATE_ISSUE); onClose(); diff --git a/app/javascript/dashboard/components/widgets/conversation/linear/IssuesList.vue b/app/javascript/dashboard/components/widgets/conversation/linear/IssuesList.vue index 160394142..a1a2f2e63 100644 --- a/app/javascript/dashboard/components/widgets/conversation/linear/IssuesList.vue +++ b/app/javascript/dashboard/components/widgets/conversation/linear/IssuesList.vue @@ -46,9 +46,9 @@ const loadLinkedIssues = async () => { } }; -const unlinkIssue = async linkId => { +const unlinkIssue = async (linkId, issueIdentifier) => { try { - await LinearAPI.unlinkIssue(linkId); + await LinearAPI.unlinkIssue(linkId, issueIdentifier, props.conversationId); useTrack(LINEAR_EVENTS.UNLINK_ISSUE); linkedIssues.value = linkedIssues.value.filter( issue => issue.id !== linkId @@ -110,7 +110,7 @@ onMounted(() => { diff --git a/app/javascript/dashboard/components/widgets/conversation/linear/LinearIssueItem.vue b/app/javascript/dashboard/components/widgets/conversation/linear/LinearIssueItem.vue index e9d1ca500..10978da39 100644 --- a/app/javascript/dashboard/components/widgets/conversation/linear/LinearIssueItem.vue +++ b/app/javascript/dashboard/components/widgets/conversation/linear/LinearIssueItem.vue @@ -14,6 +14,8 @@ const props = defineProps({ const emit = defineEmits(['unlinkIssue']); +const { linkedIssue } = props; + const priorityMap = { 1: 'Urgent', 2: 'High', @@ -21,7 +23,7 @@ const priorityMap = { 4: 'Low', }; -const issue = computed(() => props.linkedIssue.issue); +const issue = computed(() => linkedIssue.issue); const assignee = computed(() => { const assigneeDetails = issue.value.assignee; @@ -37,7 +39,7 @@ const labels = computed(() => issue.value.labels?.nodes || []); const priorityLabel = computed(() => priorityMap[issue.value.priority]); const unlinkIssue = () => { - emit('unlinkIssue', props.linkedIssue.id); + emit('unlinkIssue', linkedIssue.id, linkedIssue.issue.identifier); }; diff --git a/app/javascript/dashboard/components/widgets/conversation/linear/LinkIssue.vue b/app/javascript/dashboard/components/widgets/conversation/linear/LinkIssue.vue index e1c8e2b6c..e3b69345a 100644 --- a/app/javascript/dashboard/components/widgets/conversation/linear/LinkIssue.vue +++ b/app/javascript/dashboard/components/widgets/conversation/linear/LinkIssue.vue @@ -63,7 +63,7 @@ const onSearch = async value => { isFetching.value = true; const response = await LinearAPI.searchIssues(value); issues.value = response.data.map(issue => ({ - id: issue.id, + id: issue.identifier, name: `${issue.identifier} ${issue.title}`, icon: 'status', iconColor: issue.state.color, diff --git a/app/javascript/dashboard/composables/useInbox.js b/app/javascript/dashboard/composables/useInbox.js index 67ce11ae2..3ad308a39 100644 --- a/app/javascript/dashboard/composables/useInbox.js +++ b/app/javascript/dashboard/composables/useInbox.js @@ -125,6 +125,10 @@ export const useInbox = () => { return channelType.value === INBOX_TYPES.INSTAGRAM; }); + const isAVoiceChannel = computed(() => { + return channelType.value === INBOX_TYPES.VOICE; + }); + return { inbox, isAFacebookInbox, @@ -142,5 +146,6 @@ export const useInbox = () => { is360DialogWhatsAppChannel, isAnEmailChannel, isAnInstagramChannel, + isAVoiceChannel, }; }; diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js index ff6d73c84..ca880a79b 100644 --- a/app/javascript/dashboard/helper/inbox.js +++ b/app/javascript/dashboard/helper/inbox.js @@ -10,6 +10,7 @@ export const INBOX_TYPES = { LINE: 'Channel::Line', SMS: 'Channel::Sms', INSTAGRAM: 'Channel::Instagram', + VOICE: 'Channel::Voice', }; const INBOX_ICON_MAP_FILL = { @@ -22,6 +23,7 @@ const INBOX_ICON_MAP_FILL = { [INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill', [INBOX_TYPES.LINE]: 'i-ri-line-fill', [INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill', + [INBOX_TYPES.VOICE]: 'i-ri-phone-fill', }; const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill'; @@ -36,6 +38,7 @@ const INBOX_ICON_MAP_LINE = { [INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line', [INBOX_TYPES.LINE]: 'i-ri-line-line', [INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line', + [INBOX_TYPES.VOICE]: 'i-ri-phone-line', }; const DEFAULT_ICON_LINE = 'i-ri-chat-1-line'; @@ -47,6 +50,7 @@ export const getInboxSource = (type, phoneNumber, inbox) => { case INBOX_TYPES.TWILIO: case INBOX_TYPES.WHATSAPP: + case INBOX_TYPES.VOICE: return phoneNumber || ''; case INBOX_TYPES.EMAIL: @@ -85,6 +89,9 @@ export const getReadableInboxByType = (type, phoneNumber) => { case INBOX_TYPES.LINE: return 'line'; + case INBOX_TYPES.VOICE: + return 'voice'; + default: return 'chat'; } @@ -124,6 +131,9 @@ export const getInboxClassByType = (type, phoneNumber) => { case INBOX_TYPES.INSTAGRAM: return 'brand-instagram'; + case INBOX_TYPES.VOICE: + return 'phone'; + default: return 'chat'; } diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 3f322c7e2..eb999a0e5 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -297,6 +297,46 @@ "ERROR_MESSAGE": "We were not able to save the WhatsApp channel" } }, + "VOICE": { + "TITLE": "Voice Channel", + "DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.", + "PHONE_NUMBER": { + "LABEL": "Phone Number", + "PLACEHOLDER": "Enter your phone number (e.g. +1234567890)", + "ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)" + }, + "TWILIO": { + "ACCOUNT_SID": { + "LABEL": "Account SID", + "PLACEHOLDER": "Enter your Twilio Account SID", + "REQUIRED": "Account SID is required" + }, + "AUTH_TOKEN": { + "LABEL": "Auth Token", + "PLACEHOLDER": "Enter your Twilio Auth Token", + "REQUIRED": "Auth Token is required" + }, + "API_KEY_SID": { + "LABEL": "API Key SID", + "PLACEHOLDER": "Enter your Twilio API Key SID", + "REQUIRED": "API Key SID is required" + }, + "API_KEY_SECRET": { + "LABEL": "API Key Secret", + "PLACEHOLDER": "Enter your Twilio API Key Secret", + "REQUIRED": "API Key Secret is required" + }, + "TWIML_APP_SID": { + "LABEL": "TwiML App SID", + "PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)", + "REQUIRED": "TwiML App SID is required" + } + }, + "SUBMIT_BUTTON": "Create Voice Channel", + "API": { + "ERROR_MESSAGE": "We were not able to create the voice channel" + } + }, "API_CHANNEL": { "TITLE": "API Channel", "DESC": "Integrate with API channel and start supporting your customers.", @@ -818,7 +858,8 @@ "TELEGRAM": "Telegram", "LINE": "Line", "API": "API Channel", - "INSTAGRAM": "Instagram" + "INSTAGRAM": "Instagram", + "VOICE": "Voice" } } } diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 071c95604..41f63d0a2 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -537,6 +537,8 @@ "CONVERSATION": "Conversation #{id}" }, "SELECTED": "{count} selected", + "SELECT_ALL": "Select all ({count})", + "UNSELECT_ALL": "Unselect all ({count})", "BULK_APPROVE_BUTTON": "Approve", "BULK_DELETE_BUTTON": "Delete", "BULK_APPROVE": { diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/automation.json b/app/javascript/dashboard/i18n/locale/pt_BR/automation.json index 5b3af42a7..2c0d51696 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/automation.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/automation.json @@ -152,7 +152,7 @@ "ATTRIBUTES": { "MESSAGE_TYPE": "Tipo da Mensagem", "MESSAGE_CONTAINS": "A mensagem contém", - "EMAIL": "e-mail", + "EMAIL": "E-mail", "INBOX": "Caixa de Entrada", "CONVERSATION_LANGUAGE": "Idioma da conversa", "PHONE_NUMBER": "Número de Telefone", diff --git a/app/javascript/dashboard/modules/contact/components/MergeContact.vue b/app/javascript/dashboard/modules/contact/components/MergeContact.vue index ae0d47f7f..4055a22e4 100644 --- a/app/javascript/dashboard/modules/contact/components/MergeContact.vue +++ b/app/javascript/dashboard/modules/contact/components/MergeContact.vue @@ -130,15 +130,15 @@ export default {
-
+