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 {
-
+