Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5171c81384 |
@@ -175,15 +175,15 @@ gem 'pgvector'
|
||||
# Convert Website HTML to Markdown
|
||||
gem 'reverse_markdown'
|
||||
|
||||
gem 'opensearch-ruby'
|
||||
gem 'searchkick'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
group :production do
|
||||
# we dont want request timing out in development while using byebug
|
||||
gem 'rack-timeout'
|
||||
# for heroku autoscaling
|
||||
gem 'judoscale-rails', require: false
|
||||
gem 'judoscale-sidekiq', require: false
|
||||
end
|
||||
|
||||
group :development do
|
||||
|
||||
+8
-9
@@ -397,13 +397,6 @@ GEM
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri_template (~> 0.7)
|
||||
judoscale-rails (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
railties
|
||||
judoscale-ruby (1.8.2)
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.8.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
@@ -539,6 +532,9 @@ GEM
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
opensearch-ruby (3.4.0)
|
||||
faraday (>= 1.0, < 3)
|
||||
multi_json (>= 1.0)
|
||||
openssl (3.2.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
@@ -710,6 +706,9 @@ GEM
|
||||
parser
|
||||
scss_lint (0.60.0)
|
||||
sass (~> 3.5, >= 3.5.5)
|
||||
searchkick (5.4.0)
|
||||
activemodel (>= 6.1)
|
||||
hashie
|
||||
seed_dump (3.3.1)
|
||||
activerecord (>= 4)
|
||||
activesupport (>= 4)
|
||||
@@ -898,8 +897,6 @@ DEPENDENCIES
|
||||
jbuilder
|
||||
json_refs
|
||||
json_schemer
|
||||
judoscale-rails
|
||||
judoscale-sidekiq
|
||||
jwt
|
||||
kaminari
|
||||
koala
|
||||
@@ -919,6 +916,7 @@ DEPENDENCIES
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
opensearch-ruby
|
||||
pg
|
||||
pg_search
|
||||
pgvector
|
||||
@@ -944,6 +942,7 @@ DEPENDENCIES
|
||||
rubocop-rspec
|
||||
scout_apm
|
||||
scss_lint
|
||||
searchkick
|
||||
seed_dump
|
||||
sentry-rails (>= 5.19.0)
|
||||
sentry-ruby
|
||||
|
||||
@@ -1,53 +1,22 @@
|
||||
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
|
||||
before_action :hook
|
||||
before_action :check_admin_authorization?
|
||||
before_action :fetch_hook
|
||||
|
||||
def proxy
|
||||
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
|
||||
render plain: response.body, status: response.code
|
||||
def sso_url
|
||||
params_string =
|
||||
"token=#{URI.encode_www_form_component(@hook['settings']['access_token'])}" \
|
||||
"&email=#{URI.encode_www_form_component(@hook['settings']['account_email'])}" \
|
||||
"&account_id=#{URI.encode_www_form_component(@hook['settings']['account_id'])}"
|
||||
|
||||
installation_config = InstallationConfig.find_by(name: 'CAPTAIN_APP_URL')
|
||||
|
||||
sso_url = "#{installation_config.value}/sso?#{params_string}"
|
||||
render json: { sso_url: sso_url }, status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def headers
|
||||
{
|
||||
'X-User-Email' => hook.settings['account_email'],
|
||||
'X-User-Token' => hook.settings['access_token'],
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => '*/*'
|
||||
}
|
||||
end
|
||||
|
||||
def request_path
|
||||
request_route = with_leading_hash_on_route(params[:route])
|
||||
|
||||
return 'api/sessions/profile' if request_route == '/sessions/profile'
|
||||
|
||||
"api/accounts/#{hook.settings['account_id']}#{request_route}"
|
||||
end
|
||||
|
||||
def request_url
|
||||
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
|
||||
URI.join(base_url, request_path).to_s
|
||||
end
|
||||
|
||||
def hook
|
||||
@hook ||= Current.account.hooks.find_by!(app_id: 'captain')
|
||||
end
|
||||
|
||||
def request_method
|
||||
method = permitted_params[:method].downcase
|
||||
raise 'Invalid or missing HTTP method' unless %w[get post put patch delete options head].include?(method)
|
||||
|
||||
method
|
||||
end
|
||||
|
||||
def with_leading_hash_on_route(request_route)
|
||||
return '' if request_route.blank?
|
||||
|
||||
request_route.start_with?('/') ? request_route : "/#{request_route}"
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:method, :route, body: {})
|
||||
def fetch_hook
|
||||
@hook = Current.account.hooks.find_by!(app_id: 'captain')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,8 +33,8 @@ class IntegrationsAPI extends ApiClient {
|
||||
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
|
||||
}
|
||||
|
||||
requestCaptain(body) {
|
||||
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
|
||||
fetchCaptainURL() {
|
||||
return axios.get(`${this.baseUrl()}/integrations/captain/sso_url`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversationLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const WIDTH_CONFIG = Object.freeze({
|
||||
DEFAULT_WIDTH: 80,
|
||||
CHAR_WIDTH: {
|
||||
SHORT: 8, // For labels <= 5 chars
|
||||
LONG: 6, // For labels > 5 chars
|
||||
},
|
||||
BASE_WIDTH: 12, // dot + gap
|
||||
THRESHOLD: 5, // character length threshold
|
||||
});
|
||||
|
||||
const containerRef = ref(null);
|
||||
const maxLabels = ref(1);
|
||||
|
||||
const activeLabels = computed(() => {
|
||||
const labelSet = new Set(props.conversationLabels);
|
||||
return props.accountLabels?.filter(({ title }) => labelSet.has(title));
|
||||
});
|
||||
|
||||
const calculateLabelWidth = ({ title = '' }) => {
|
||||
const charWidth =
|
||||
title.length > WIDTH_CONFIG.THRESHOLD
|
||||
? WIDTH_CONFIG.CHAR_WIDTH.LONG
|
||||
: WIDTH_CONFIG.CHAR_WIDTH.SHORT;
|
||||
|
||||
return title.length * charWidth + WIDTH_CONFIG.BASE_WIDTH;
|
||||
};
|
||||
|
||||
const getAverageWidth = labels => {
|
||||
if (!labels.length) return WIDTH_CONFIG.DEFAULT_WIDTH;
|
||||
|
||||
const totalWidth = labels.reduce(
|
||||
(sum, label) => sum + calculateLabelWidth(label),
|
||||
0
|
||||
);
|
||||
|
||||
return totalWidth / labels.length;
|
||||
};
|
||||
|
||||
const visibleLabels = computed(() =>
|
||||
activeLabels.value?.slice(0, maxLabels.value)
|
||||
);
|
||||
|
||||
const updateVisibleLabels = () => {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
const containerWidth = containerRef.value.offsetWidth;
|
||||
const avgWidth = getAverageWidth(activeLabels.value);
|
||||
|
||||
maxLabels.value = Math.max(1, Math.floor(containerWidth / avgWidth));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
v-resize="updateVisibleLabels"
|
||||
class="flex items-center gap-2.5 w-full min-w-0 h-6 overflow-hidden"
|
||||
>
|
||||
<template v-for="(label, index) in visibleLabels" :key="label.id">
|
||||
<div
|
||||
class="flex items-center gap-1.5 min-w-0"
|
||||
:class="[
|
||||
index !== visibleLabels.length - 1
|
||||
? 'flex-shrink-0 text-ellipsis'
|
||||
: 'flex-shrink',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:style="{ backgroundColor: label.color }"
|
||||
class="size-1.5 rounded-full flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
class="text-sm text-n-slate-10 whitespace-nowrap"
|
||||
:class="{ truncate: index === visibleLabels.length - 1 }"
|
||||
>
|
||||
{{ label.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
const { lastNonActivityMessage = {} } = props.conversation;
|
||||
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const { meta: { assignee: agent = {} } = {} } = props.conversation;
|
||||
return {
|
||||
name: agent.name ?? agent.availableName,
|
||||
thumbnail: agent.thumbnail,
|
||||
status: agent.availabilityStatus,
|
||||
};
|
||||
});
|
||||
|
||||
const unreadMessagesCount = computed(() => {
|
||||
const { unreadCount } = props.conversation;
|
||||
return unreadCount;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-end w-full gap-2 pb-1">
|
||||
<p class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||
<Avatar
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
:status="assignee.status"
|
||||
rounded-full
|
||||
/>
|
||||
<div
|
||||
v-if="unreadMessagesCount > 0"
|
||||
class="inline-flex items-center justify-center rounded-full size-5 bg-n-brand"
|
||||
>
|
||||
<span class="text-xs font-semibold text-white">
|
||||
{{ unreadMessagesCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
const { lastNonActivityMessage = {} } = props.conversation;
|
||||
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const { meta: { assignee: agent = {} } = {} } = props.conversation;
|
||||
return {
|
||||
name: agent.name ?? agent.availableName,
|
||||
thumbnail: agent.thumbnail,
|
||||
status: agent.availabilityStatus,
|
||||
};
|
||||
});
|
||||
|
||||
const unreadMessagesCount = computed(() => {
|
||||
const { unreadCount } = props.conversation;
|
||||
return unreadCount;
|
||||
});
|
||||
|
||||
const hasSlaThreshold = computed(() => props.conversation?.slaPolicyId);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
|
||||
<p class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="unreadMessagesCount > 0"
|
||||
class="inline-flex items-center justify-center flex-shrink-0 rounded-full size-5 bg-n-brand"
|
||||
>
|
||||
<span class="text-xs font-semibold text-white">
|
||||
{{ unreadMessagesCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid items-center gap-2.5 h-7"
|
||||
:class="
|
||||
hasSlaThreshold
|
||||
? 'grid-cols-[auto_auto_1fr_20px]'
|
||||
: 'grid-cols-[1fr_20px]'
|
||||
"
|
||||
>
|
||||
<SLACardLabel v-if="hasSlaThreshold" :conversation="conversation" />
|
||||
<div v-if="hasSlaThreshold" class="w-px h-3 bg-n-slate-4" />
|
||||
<div class="overflow-hidden">
|
||||
<CardLabels
|
||||
:conversation-labels="conversation.labels"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</div>
|
||||
<Avatar
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
:status="assignee.status"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-207
@@ -1,207 +0,0 @@
|
||||
<script setup>
|
||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||
|
||||
defineProps({
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- Low Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Medium Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- High Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.HIGH"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Urgent Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.URGENT"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
-477
@@ -1,477 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import ConversationCard from './ConversationCard.vue';
|
||||
|
||||
// Base conversation object
|
||||
const conversationWithoutMeta = {
|
||||
meta: {
|
||||
sender: {
|
||||
additionalAttributes: {},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'candice@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Candice Matherson',
|
||||
phone_number: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
linkContact: 'https://apple.com',
|
||||
listContact: 'Not spam',
|
||||
textContact: 'hey',
|
||||
checkboxContact: true,
|
||||
},
|
||||
last_activity_at: 1712127410,
|
||||
created_at: 1712127389,
|
||||
},
|
||||
channel: 'Channel::Email',
|
||||
assignee: {
|
||||
id: 1,
|
||||
accountId: 2,
|
||||
availabilityStatus: 'online',
|
||||
autoOffline: false,
|
||||
confirmed: true,
|
||||
email: 'sivin@chatwoot.com',
|
||||
availableName: 'Sivin',
|
||||
name: 'Sivin',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
customRoleId: null,
|
||||
},
|
||||
hmacVerified: false,
|
||||
},
|
||||
id: 38,
|
||||
messages: [
|
||||
{
|
||||
id: 3597,
|
||||
content: 'Sivin set the priority to low',
|
||||
accountId: 2,
|
||||
inboxId: 7,
|
||||
conversationId: 38,
|
||||
messageType: 2,
|
||||
createdAt: 1730885168,
|
||||
updatedAt: '2024-11-06T09:26:08.565Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
contentType: 'text',
|
||||
contentAttributes: {},
|
||||
senderType: null,
|
||||
senderId: null,
|
||||
externalSourceIds: {},
|
||||
additionalAttributes: {},
|
||||
processedMessageContent: 'Sivin set the priority to low',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assigneeId: 1,
|
||||
unreadCount: 0,
|
||||
lastActivityAt: 1730885168,
|
||||
contactInbox: {
|
||||
sourceId: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
accountId: 2,
|
||||
uuid: '21bd8638-a711-4080-b4ac-7fda1bc71837',
|
||||
additionalAttributes: {
|
||||
mail_subject: 'Test email',
|
||||
},
|
||||
agentLastSeenAt: 0,
|
||||
assigneeLastSeenAt: 0,
|
||||
canReply: true,
|
||||
contactLastSeenAt: 0,
|
||||
customAttributes: {},
|
||||
inboxId: 7,
|
||||
labels: [],
|
||||
status: 'open',
|
||||
createdAt: 1730836533,
|
||||
timestamp: 1730885168,
|
||||
firstReplyCreatedAt: 1730836533,
|
||||
unreadCount: 0,
|
||||
lastNonActivityMessage: {
|
||||
id: 3591,
|
||||
content:
|
||||
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
|
||||
account_id: 2,
|
||||
inbox_id: 7,
|
||||
conversation_id: 38,
|
||||
message_type: 1,
|
||||
created_at: 1730836533,
|
||||
updated_at: '2024-11-05T19:55:37.158Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id:
|
||||
'conversation/21bd8638-a711-4080-b4ac-7fda1bc71837/messages/3591@paperlayer.test',
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
cc_emails: ['test@gmail.com'],
|
||||
bcc_emails: [],
|
||||
to_emails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1730885168,
|
||||
contact_inbox: {
|
||||
source_id: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
available_name: 'Sivin',
|
||||
avatar_url: '',
|
||||
type: 'user',
|
||||
availability_status: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
lastActivityAt: 1730885168,
|
||||
priority: 'low',
|
||||
waitingSince: 0,
|
||||
slaPolicyId: null,
|
||||
slaEvents: [],
|
||||
};
|
||||
|
||||
const conversationWithMeta = {
|
||||
meta: {
|
||||
sender: {
|
||||
additionalAttributes: {},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'willy@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Willy Castelot',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
linkContact: 'https://apple.com',
|
||||
listContact: 'Not spam',
|
||||
textContact: 'hey',
|
||||
checkboxContact: true,
|
||||
},
|
||||
lastActivityAt: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
},
|
||||
channel: 'Channel::Email',
|
||||
assignee: {
|
||||
id: 1,
|
||||
accountId: 2,
|
||||
availabilityStatus: 'online',
|
||||
autoOffline: false,
|
||||
confirmed: true,
|
||||
email: 'sivin@chatwoot.com',
|
||||
availableName: 'Sivin',
|
||||
name: 'Sivin',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
customRoleId: null,
|
||||
},
|
||||
hmacVerified: false,
|
||||
},
|
||||
id: 37,
|
||||
messages: [
|
||||
{
|
||||
id: 3599,
|
||||
content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
accountId: 2,
|
||||
inboxId: 7,
|
||||
conversationId: 37,
|
||||
messageType: 1,
|
||||
createdAt: 1730885428,
|
||||
updatedAt: '2024-11-06T09:30:30.619Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
sourceId:
|
||||
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
|
||||
contentType: 'text',
|
||||
contentAttributes: {
|
||||
ccEmails: [],
|
||||
bccEmails: [],
|
||||
toEmails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
senderId: 1,
|
||||
externalSourceIds: {},
|
||||
additionalAttributes: {},
|
||||
processedMessageContent:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1730885428,
|
||||
contact_inbox: {
|
||||
source_id: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
availableName: 'Sivin',
|
||||
avatarUrl: '',
|
||||
type: 'user',
|
||||
availabilityStatus: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
accountId: 2,
|
||||
uuid: '53df668d-329d-420e-8fe9-980cb0e4d63c',
|
||||
additionalAttributes: {
|
||||
mail_subject: 'we',
|
||||
},
|
||||
agentLastSeenAt: 1730885428,
|
||||
assigneeLastSeenAt: 1730885428,
|
||||
canReply: true,
|
||||
contactLastSeenAt: 0,
|
||||
customAttributes: {},
|
||||
inboxId: 7,
|
||||
labels: [
|
||||
'billing',
|
||||
'delivery',
|
||||
'lead',
|
||||
'premium-customer',
|
||||
'software',
|
||||
'ops-handover',
|
||||
],
|
||||
muted: false,
|
||||
snoozedUntil: null,
|
||||
status: 'open',
|
||||
createdAt: 1722487645,
|
||||
timestamp: 1730885428,
|
||||
firstReplyCreatedAt: 1722487645,
|
||||
unreadCount: 0,
|
||||
lastNonActivityMessage: {
|
||||
id: 3599,
|
||||
content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
account_id: 2,
|
||||
inbox_id: 7,
|
||||
conversation_id: 37,
|
||||
message_type: 1,
|
||||
created_at: 1730885428,
|
||||
updated_at: '2024-11-06T09:30:30.619Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id:
|
||||
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
cc_emails: [],
|
||||
bcc_emails: [],
|
||||
to_emails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 2,
|
||||
last_activity_at: 1730885428,
|
||||
contact_inbox: {
|
||||
source_id: 'willy@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
available_name: 'Sivin',
|
||||
avatar_url: '',
|
||||
type: 'user',
|
||||
availability_status: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
lastActivityAt: 1730885428,
|
||||
priority: 'urgent',
|
||||
waitingSince: 1730885428,
|
||||
slaPolicyId: 3,
|
||||
appliedSla: {
|
||||
id: 4,
|
||||
sla_id: 3,
|
||||
sla_status: 'active_with_misses',
|
||||
created_at: 1712127410,
|
||||
updated_at: 1712127545,
|
||||
sla_description:
|
||||
'Premium Service Level Agreements (SLAs) are contracts that define clear expectations ',
|
||||
sla_name: 'Premium SLA',
|
||||
sla_first_response_time_threshold: 120,
|
||||
sla_next_response_time_threshold: 180,
|
||||
sla_only_during_business_hours: false,
|
||||
sla_resolution_time_threshold: 360,
|
||||
},
|
||||
slaEvents: [
|
||||
{
|
||||
id: 8,
|
||||
event_type: 'frt',
|
||||
meta: {},
|
||||
updated_at: 1712127545,
|
||||
created_at: 1712127545,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
event_type: 'rt',
|
||||
meta: {},
|
||||
updated_at: 1712127790,
|
||||
created_at: 1712127790,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const contactForConversationWithoutMeta = computed(() => ({
|
||||
availabilityStatus: null,
|
||||
email: 'candice@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Candice Matherson',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=George',
|
||||
customAttributes: {},
|
||||
last_activity_at: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
contactInboxes: [],
|
||||
}));
|
||||
|
||||
const contactForConversationWithMeta = computed(() => ({
|
||||
availabilityStatus: null,
|
||||
email: 'willy@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Willy Castelot',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=Liam',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
contactInboxes: [],
|
||||
}));
|
||||
|
||||
const webWidgetInbox = computed(() => ({
|
||||
phone_number: '+918585858585',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
}));
|
||||
|
||||
const accountLabels = computed(() => [
|
||||
{
|
||||
id: 1,
|
||||
title: 'billing',
|
||||
description: 'Label is used for tagging billing related conversations',
|
||||
color: '#28AD21',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'delivery',
|
||||
description: null,
|
||||
color: '#A2FDD5',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'lead',
|
||||
description: null,
|
||||
color: '#F161C8',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'ops-handover',
|
||||
description: null,
|
||||
color: '#A53326',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'premium-customer',
|
||||
description: null,
|
||||
color: '#6FD4EF',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'software',
|
||||
description: null,
|
||||
color: '#8F6EF2',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/ConversationCard"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Conversation without meta">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithoutMeta.id"
|
||||
:conversation="conversationWithoutMeta"
|
||||
:contact="contactForConversationWithoutMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation with meta (SLA, Labels)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithMeta,
|
||||
priority: 'medium',
|
||||
}"
|
||||
:contact="contactForConversationWithMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation without meta (Unread count)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithoutMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithoutMeta,
|
||||
unreadCount: 2,
|
||||
priority: 'high',
|
||||
}"
|
||||
:contact="contactForConversationWithoutMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation with meta (SLA, Labels, Unread count)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithMeta,
|
||||
unreadCount: 2,
|
||||
}"
|
||||
:contact="contactForConversationWithMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardMessagePreview from './CardMessagePreview.vue';
|
||||
import CardMessagePreviewWithMeta from './CardMessagePreviewWithMeta.vue';
|
||||
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
contact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
stateInbox: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const currentContact = computed(() => props.contact);
|
||||
|
||||
const currentContactName = computed(() => currentContact.value?.name);
|
||||
const currentContactThumbnail = computed(() => currentContact.value?.thumbnail);
|
||||
const currentContactStatus = computed(
|
||||
() => currentContact.value?.availabilityStatus
|
||||
);
|
||||
|
||||
const inbox = computed(() => props.stateInbox);
|
||||
|
||||
const inboxName = computed(() => inbox.value?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { phoneNumber, channelType } = inbox.value;
|
||||
return getInboxIconByType(channelType, phoneNumber);
|
||||
});
|
||||
|
||||
const lastActivityAt = computed(() => {
|
||||
const timestamp = props.conversation?.timestamp;
|
||||
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
|
||||
});
|
||||
|
||||
const showMessagePreviewWithoutMeta = computed(() => {
|
||||
const { slaPolicyId, labels = [] } = props.conversation;
|
||||
return !slaPolicyId && labels.length === 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full gap-3 px-3 py-4 transition-colors duration-300 ease-in-out rounded-xl"
|
||||
>
|
||||
<Avatar
|
||||
:name="currentContactName"
|
||||
:src="currentContactThumbnail"
|
||||
:size="24"
|
||||
:status="currentContactStatus"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<div class="flex items-center justify-between h-6 gap-2">
|
||||
<h4 class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ currentContactName }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-2">
|
||||
<CardPriorityIcon :priority="conversation.priority || null" />
|
||||
<div
|
||||
v-tooltip.top-start="inboxName"
|
||||
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
|
||||
>
|
||||
<Icon
|
||||
:icon="inboxIcon"
|
||||
class="flex-shrink-0 text-n-slate-11 size-3"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10">
|
||||
{{ lastActivityAt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardMessagePreview
|
||||
v-if="showMessagePreviewWithoutMeta"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<CardMessagePreviewWithMeta
|
||||
v-else
|
||||
:conversation="conversation"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
const timer = ref(null);
|
||||
const slaStatus = ref({
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
type: null,
|
||||
icon: null,
|
||||
});
|
||||
|
||||
// TODO: Remove this once we update the helper from utils
|
||||
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
|
||||
const convertObjectCamelCaseToSnakeCase = object => {
|
||||
return Object.keys(object).reduce((acc, key) => {
|
||||
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const appliedSLA = computed(() => props.conversation?.appliedSla);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const slaStatusText = computed(() => {
|
||||
return slaStatus.value?.type?.toUpperCase();
|
||||
});
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value),
|
||||
chat: props.conversation,
|
||||
});
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}, REFRESH_INTERVAL);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.conversation, updateSlaStatus);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center min-w-fit gap-0.5 h-6">
|
||||
<div class="flex items-center justify-center size-4">
|
||||
<svg
|
||||
width="10"
|
||||
height="13"
|
||||
viewBox="0 0 10 13"
|
||||
fill="none"
|
||||
:class="isSlaMissed ? 'fill-n-ruby-10' : 'fill-n-slate-9'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.55091 12.412C7.44524 12.412 9.37939 10.4571 9.37939 7.51446C9.37939 2.63072 5.21405 0.599854 2.36808 0.599854C1.81546 0.599854 1.45626 0.800176 1.45626 1.1801C1.45626 1.32516 1.52534 1.48404 1.64277 1.62219C2.27828 2.38204 2.92069 3.27314 2.93451 4.36455C2.93451 4.5925 2.9276 4.78592 2.76181 5.08295L3.05194 5.03459C2.81017 4.21949 2.18848 3.63234 1.5806 3.63234C1.32501 3.63234 1.15232 3.81884 1.15232 4.09514C1.15232 4.23331 1.19377 4.56488 1.19377 4.79974C1.19377 5.95332 0.26123 6.69935 0.26123 8.67495C0.26123 10.92 1.97434 12.412 4.55091 12.412ZM4.68906 10.8923C3.65982 10.8923 2.96905 10.2637 2.96905 9.33119C2.96905 8.3572 3.66672 8.01181 3.75652 7.38322C3.76344 7.32796 3.79107 7.31414 3.83251 7.34867C4.08809 7.57663 4.24697 7.85293 4.37822 8.1776C4.67525 7.77696 4.81341 6.9204 4.73051 6.0293C4.72361 5.97404 4.75814 5.94642 4.80649 5.96713C6.02916 6.53357 6.65085 7.74241 6.65085 8.82693C6.65085 9.92527 6.00844 10.8923 4.68906 10.8923Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="text-sm truncate"
|
||||
:class="isSlaMissed ? 'text-n-ruby-11' : 'text-n-slate-11'"
|
||||
>
|
||||
{{ `${slaStatusText}: ${slaStatus.threshold}` }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
+1
-1
@@ -35,7 +35,7 @@ const emit = defineEmits([
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const saveArticle = debounce(value => emit('saveArticle', value), 600, false);
|
||||
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
|
||||
|
||||
const articleTitle = computed({
|
||||
get: () => props.article.title,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { removeEmoji } from 'shared/helpers/emoji';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -30,114 +27,34 @@ const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: null,
|
||||
validator: value =>
|
||||
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
default: null,
|
||||
validator: value => {
|
||||
if (!value) return true;
|
||||
return wootConstants.AVAILABILITY_STATUS_KEYS.includes(value);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['upload']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isImageValid = ref(true);
|
||||
|
||||
const AVATAR_COLORS = {
|
||||
dark: [
|
||||
['#4B143D', '#FF8DCC'],
|
||||
['#3F220D', '#FFA366'],
|
||||
['#2A2A2A', '#ADB1B8'],
|
||||
['#023B37', '#0BD8B6'],
|
||||
['#27264D', '#A19EFF'],
|
||||
['#1D2E62', '#9EB1FF'],
|
||||
],
|
||||
light: [
|
||||
['#FBDCEF', '#C2298A'],
|
||||
['#FFE0BB', '#99543A'],
|
||||
['#E8E8E8', '#60646C'],
|
||||
['#CCF3EA', '#008573'],
|
||||
['#EBEBFE', '#4747C2'],
|
||||
['#E1E9FF', '#3A5BC7'],
|
||||
],
|
||||
default: { bg: '#E8E8E8', text: '#60646C' },
|
||||
};
|
||||
|
||||
const STATUS_CLASSES = {
|
||||
online: 'bg-n-teal-10',
|
||||
busy: 'bg-n-amber-10',
|
||||
offline: 'bg-n-slate-10',
|
||||
};
|
||||
|
||||
const showDefaultAvatar = computed(() => !props.src && !props.name);
|
||||
function invalidateCurrentImage() {
|
||||
isImageValid.value = false;
|
||||
}
|
||||
|
||||
const initials = computed(() => {
|
||||
if (!props.name) return '';
|
||||
const words = removeEmoji(props.name).split(/\s+/);
|
||||
return words.length === 1
|
||||
? words[0].charAt(0).toUpperCase()
|
||||
: words
|
||||
.slice(0, 2)
|
||||
.map(word => word.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
const splitNames = props.name.split(' ');
|
||||
|
||||
if (splitNames.length > 1) {
|
||||
const firstName = splitNames[0];
|
||||
const lastName = splitNames[splitNames.length - 1];
|
||||
|
||||
return firstName[0] + lastName[0];
|
||||
}
|
||||
|
||||
const firstName = splitNames[0];
|
||||
return firstName[0];
|
||||
});
|
||||
|
||||
const getColorsByNameLength = computed(() => {
|
||||
if (!props.name) return AVATAR_COLORS.default;
|
||||
|
||||
const index = props.name.length % AVATAR_COLORS.light.length;
|
||||
return {
|
||||
bg: AVATAR_COLORS.light[index][0],
|
||||
darkBg: AVATAR_COLORS.dark[index][0],
|
||||
text: AVATAR_COLORS.light[index][1],
|
||||
darkText: AVATAR_COLORS.dark[index][1],
|
||||
};
|
||||
});
|
||||
|
||||
const containerStyles = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
}));
|
||||
|
||||
const avatarStyles = computed(() => ({
|
||||
...containerStyles.value,
|
||||
backgroundColor:
|
||||
!showDefaultAvatar.value && (!props.src || !isImageValid.value)
|
||||
? getColorsByNameLength.value.bg
|
||||
: undefined,
|
||||
color:
|
||||
!showDefaultAvatar.value && (!props.src || !isImageValid.value)
|
||||
? getColorsByNameLength.value.text
|
||||
: undefined,
|
||||
'--dark-bg': getColorsByNameLength.value.darkBg,
|
||||
'--dark-text': getColorsByNameLength.value.darkText,
|
||||
}));
|
||||
|
||||
const badgeStyles = computed(() => {
|
||||
const badgeSize = Math.max(props.size * 0.35, 8); // 35% of avatar size, minimum 8px
|
||||
return {
|
||||
width: `${badgeSize}px`,
|
||||
height: `${badgeSize}px`,
|
||||
top: `${props.size - badgeSize / 1.1}px`,
|
||||
left: `${props.size - badgeSize / 1.1}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const iconStyles = computed(() => ({
|
||||
fontSize: `${props.size / 1.6}px`,
|
||||
}));
|
||||
|
||||
const initialsStyles = computed(() => ({
|
||||
fontSize: `${props.size / 1.8}px`,
|
||||
}));
|
||||
|
||||
const invalidateCurrentImage = () => {
|
||||
isImageValid.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
@@ -147,62 +64,48 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="relative inline-flex" :style="containerStyles">
|
||||
<!-- Status Badge -->
|
||||
<slot name="badge" :size="size">
|
||||
<span
|
||||
class="relative inline"
|
||||
:style="{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}"
|
||||
>
|
||||
<slot name="badge" :size>
|
||||
<div
|
||||
v-if="status"
|
||||
class="absolute z-20 border rounded-full border-n-slate-3"
|
||||
:style="badgeStyles"
|
||||
:class="STATUS_CLASSES[status]"
|
||||
class="rounded-full w-2.5 h-2.5 absolute z-20"
|
||||
:style="{
|
||||
top: `${size - 10}px`,
|
||||
left: `${size - 10}px`,
|
||||
}"
|
||||
:class="{
|
||||
'bg-n-teal-10': status === 'online',
|
||||
'bg-n-amber-10': status === 'busy',
|
||||
'bg-n-slate-10': status === 'offline',
|
||||
}"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
<!-- Avatar Container -->
|
||||
<span
|
||||
role="img"
|
||||
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium"
|
||||
:class="[
|
||||
roundedFull ? 'rounded-full' : 'rounded-xl',
|
||||
{
|
||||
'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]':
|
||||
!showDefaultAvatar && (!src || !isImageValid),
|
||||
'bg-n-slate-3 dark:bg-n-slate-4': showDefaultAvatar,
|
||||
},
|
||||
]"
|
||||
:style="avatarStyles"
|
||||
class="inline-flex relative items-center justify-center object-cover overflow-hidden font-medium bg-woot-50 text-woot-500 group/avatar"
|
||||
:class="{
|
||||
'rounded-full': roundedFull,
|
||||
'rounded-xl': !roundedFull,
|
||||
}"
|
||||
:style="{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}"
|
||||
>
|
||||
<!-- Avatar Content -->
|
||||
<img
|
||||
v-if="src && isImageValid"
|
||||
:src="src"
|
||||
:alt="name"
|
||||
@error="invalidateCurrentImage"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- Custom Icon -->
|
||||
<Icon v-if="iconName" :icon="iconName" :style="iconStyles" />
|
||||
|
||||
<!-- Initials -->
|
||||
<span
|
||||
v-else-if="!showDefaultAvatar"
|
||||
:style="initialsStyles"
|
||||
class="select-none"
|
||||
>
|
||||
{{ initials }}
|
||||
</span>
|
||||
|
||||
<!-- Fallback Icon if no name or image -->
|
||||
<Icon
|
||||
v-else
|
||||
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
|
||||
icon="i-lucide-user"
|
||||
:style="iconStyles"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Upload Overlay -->
|
||||
<span v-else>
|
||||
{{ initials }}
|
||||
</span>
|
||||
<div
|
||||
v-if="allowUpload"
|
||||
role="button"
|
||||
|
||||
@@ -164,25 +164,9 @@ const menuItems = computed(() => {
|
||||
},
|
||||
{
|
||||
name: 'Captain',
|
||||
icon: 'i-woot-captain',
|
||||
icon: 'i-lucide-bot',
|
||||
label: t('SIDEBAR.CAPTAIN'),
|
||||
children: [
|
||||
{
|
||||
name: 'Documents',
|
||||
label: 'Documents',
|
||||
to: accountScopedRoute('captain', { page: 'documents' }),
|
||||
},
|
||||
{
|
||||
name: 'Responses',
|
||||
label: 'Responses',
|
||||
to: accountScopedRoute('captain', { page: 'responses' }),
|
||||
},
|
||||
{
|
||||
name: 'Playground',
|
||||
label: 'Playground',
|
||||
to: accountScopedRoute('captain', { page: 'playground' }),
|
||||
},
|
||||
],
|
||||
to: accountScopedRoute('captain'),
|
||||
},
|
||||
{
|
||||
name: 'Contacts',
|
||||
|
||||
@@ -122,9 +122,7 @@ const toggleTrigger = () => {
|
||||
<template v-for="child in children" :key="child.name">
|
||||
<SidebarSubGroup
|
||||
v-if="child.children"
|
||||
:label="child.label"
|
||||
:icon="child.icon"
|
||||
:children="child.children"
|
||||
v-bind="child"
|
||||
:is-expanded="isExpanded"
|
||||
:active-child="activeChild"
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,7 @@ const primaryMenuItems = accountId => [
|
||||
key: 'captain',
|
||||
label: 'CAPTAIN',
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
toState: frontendURL(`accounts/${accountId}/captain/documents`),
|
||||
toState: frontendURL(`accounts/${accountId}/captain`),
|
||||
toStateName: 'captain',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,107 +1,75 @@
|
||||
<script setup>
|
||||
import { nextTick, watch, computed } from 'vue';
|
||||
import IntegrationsAPI from 'dashboard/api/integrations';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { makeRouter, setupApp } from '@chatwoot/captain';
|
||||
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
import integrations from '../../api/integrations';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
const isLoading = ref(true);
|
||||
const captainURL = ref('');
|
||||
const hasError = ref(false);
|
||||
|
||||
const loadCaptainFrame = async integration => {
|
||||
if (!integration || !integration.enabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const { data } = await integrations.fetchCaptainURL();
|
||||
captainURL.value = data.sso_url;
|
||||
} catch (error) {
|
||||
hasError.value = true;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const routeMap = {
|
||||
documents: '/app/accounts/[account_id]/documents/',
|
||||
playground: '/app/accounts/[account_id]/playground/',
|
||||
responses: '/app/accounts/[account_id]/responses/',
|
||||
};
|
||||
|
||||
const resolvedRoute = computed(() => routeMap[props.page]);
|
||||
|
||||
let router = null;
|
||||
|
||||
watch(
|
||||
() => props.page,
|
||||
() => {
|
||||
if (router) {
|
||||
router.push({ name: resolvedRoute.value });
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const buildApp = () => {
|
||||
router = makeRouter();
|
||||
setupApp('#captain', {
|
||||
router,
|
||||
fetchFn: async (source, options) => {
|
||||
const parsedSource = new URL(source);
|
||||
let path = parsedSource.pathname;
|
||||
if (path === `/api/sessions/profile`) {
|
||||
path = '/sessions/profile';
|
||||
} else {
|
||||
path = path.replace(/^\/api\/accounts\/\d+/, '');
|
||||
}
|
||||
|
||||
// include search params
|
||||
path = `${path}${parsedSource.search}`;
|
||||
|
||||
const response = await IntegrationsAPI.requestCaptain({
|
||||
method: options.method ?? 'GET',
|
||||
route: path,
|
||||
body: options.body ? JSON.parse(options.body) : null,
|
||||
});
|
||||
|
||||
return {
|
||||
json: () => {
|
||||
return response.data;
|
||||
},
|
||||
ok: response.status >= 200 && response.status < 300,
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
router.push({ name: resolvedRoute.value });
|
||||
};
|
||||
|
||||
const captainIntegration = computed(() =>
|
||||
getters['integrations/getIntegration'].value('captain', null)
|
||||
);
|
||||
|
||||
watch(
|
||||
() => captainIntegration.value,
|
||||
(newValue, prevValue) => {
|
||||
if (!prevValue && newValue) {
|
||||
nextTick(() => buildApp());
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
onMounted(() => loadCaptainFrame(captainIntegration.value));
|
||||
|
||||
watch(captainIntegration, updatedIntegration =>
|
||||
loadCaptainFrame(updatedIntegration)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!captainIntegration">
|
||||
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!captainIntegration.enabled"
|
||||
class="flex-1 flex flex-col gap-2 items-center justify-center"
|
||||
class="flex-1 overflow-auto flex gap-8 flex-col font-inter text-slate-900 dark:text-slate-500"
|
||||
>
|
||||
<div>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}</div>
|
||||
<router-link :to="{ name: 'settings_applications' }">
|
||||
<woot-button class="clear link">
|
||||
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.CLICK_HERE_TO_CONFIGURE') }}
|
||||
</woot-button>
|
||||
</router-link>
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div v-if="!captainIntegration">
|
||||
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!captainIntegration.enabled"
|
||||
class="flex-1 flex flex-col gap-2 items-center justify-center"
|
||||
>
|
||||
<div>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}</div>
|
||||
<router-link :to="{ name: 'settings_applications' }">
|
||||
<woot-button class="clear link">
|
||||
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.CLICK_HERE_TO_CONFIGURE') }}
|
||||
</woot-button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isLoading"
|
||||
class="flex-1 flex items-center justify-center"
|
||||
>
|
||||
<Spinner color-scheme="primary" />
|
||||
<span>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.LOADING_CONSOLE') }}</span>
|
||||
</div>
|
||||
<div v-else-if="!isLoading && hasError">
|
||||
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.FAILED_TO_LOAD_CONSOLE') }}
|
||||
</div>
|
||||
<iframe
|
||||
v-else-if="!isLoading && captainURL"
|
||||
:src="captainURL"
|
||||
class="w-full min-h-[800px] h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else id="captain" class="w-full" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import '@chatwoot/captain/dist/style.css';
|
||||
</style>
|
||||
|
||||
@@ -21,14 +21,13 @@ export default {
|
||||
component: AppContainer,
|
||||
children: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:page'),
|
||||
path: frontendURL('accounts/:accountId/captain'),
|
||||
name: 'captain',
|
||||
component: Captain,
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
},
|
||||
props: true,
|
||||
},
|
||||
...inboxRoutes,
|
||||
...conversation.routes,
|
||||
|
||||
+21
-23
@@ -6,6 +6,16 @@ import { GROUP_BY_FILTER } from '../constants';
|
||||
import { generateFileName } from '../../../../../helper/downloadHelper';
|
||||
import { REPORTS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
|
||||
|
||||
const REPORTS_KEYS = {
|
||||
CONVERSATIONS: 'conversations_count',
|
||||
INCOMING_MESSAGES: 'incoming_messages_count',
|
||||
OUTGOING_MESSAGES: 'outgoing_messages_count',
|
||||
FIRST_RESPONSE_TIME: 'avg_first_response_time',
|
||||
RESOLUTION_TIME: 'avg_resolution_time',
|
||||
RESOLUTION_COUNT: 'resolutions_count',
|
||||
REPLY_TIME: 'reply_time',
|
||||
};
|
||||
|
||||
const GROUP_BY_OPTIONS = {
|
||||
DAY: [{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' }],
|
||||
WEEK: [
|
||||
@@ -62,22 +72,6 @@ export default {
|
||||
filterItemsList() {
|
||||
return this.$store.getters[this.getterKey] || [];
|
||||
},
|
||||
isAgentType() {
|
||||
return this.type === 'agent';
|
||||
},
|
||||
reportKeys() {
|
||||
return {
|
||||
CONVERSATIONS: 'conversations_count',
|
||||
...(!this.isAgentType && {
|
||||
INCOMING_MESSAGES: 'incoming_messages_count',
|
||||
}),
|
||||
OUTGOING_MESSAGES: 'outgoing_messages_count',
|
||||
FIRST_RESPONSE_TIME: 'avg_first_response_time',
|
||||
RESOLUTION_TIME: 'avg_resolution_time',
|
||||
RESOLUTION_COUNT: 'resolutions_count',
|
||||
REPLY_TIME: 'reply_time',
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch(this.actionKey);
|
||||
@@ -98,11 +92,19 @@ export default {
|
||||
}
|
||||
},
|
||||
fetchChartData() {
|
||||
Object.keys(this.reportKeys).forEach(async key => {
|
||||
[
|
||||
'CONVERSATIONS',
|
||||
'INCOMING_MESSAGES',
|
||||
'OUTGOING_MESSAGES',
|
||||
'FIRST_RESPONSE_TIME',
|
||||
'RESOLUTION_TIME',
|
||||
'RESOLUTION_COUNT',
|
||||
'REPLY_TIME',
|
||||
].forEach(async key => {
|
||||
try {
|
||||
const { from, to, groupBy, businessHours } = this;
|
||||
this.$store.dispatch('fetchAccountReport', {
|
||||
metric: this.reportKeys[key],
|
||||
metric: REPORTS_KEYS[key],
|
||||
from,
|
||||
to,
|
||||
type: this.type,
|
||||
@@ -218,10 +220,6 @@ export default {
|
||||
@group-by-filter-change="onGroupByFilterChange"
|
||||
@business-hours-toggle="onBusinessHoursToggle"
|
||||
/>
|
||||
<ReportContainer
|
||||
v-if="filterItemsList.length"
|
||||
:group-by="groupBy"
|
||||
:report-keys="reportKeys"
|
||||
/>
|
||||
<ReportContainer v-if="filterItemsList.length" :group-by="groupBy" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,6 @@ import './design-system/histoire.scss';
|
||||
import { defineSetupVue3 } from '@histoire/plugin-vue';
|
||||
import i18nMessages from 'dashboard/i18n';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { vResizeObserver } from '@vueuse/components';
|
||||
import store from 'dashboard/store';
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -14,5 +13,4 @@ const i18n = createI18n({
|
||||
export const setupVue3 = defineSetupVue3(({ app }) => {
|
||||
app.use(store);
|
||||
app.use(i18n);
|
||||
app.directive('resize', vResizeObserver);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class Migration::Search::MessageReindexJob < ApplicationJob
|
||||
queue :searchkick
|
||||
|
||||
|
||||
end
|
||||
@@ -38,6 +38,10 @@
|
||||
#
|
||||
|
||||
class Message < ApplicationRecord
|
||||
searchkick batch_size: 10_000 if SearchConfig.enabled?
|
||||
|
||||
scope :search_import, -> { includes(:inbox, :conversation, :sender) }
|
||||
|
||||
include MessageFilterHelpers
|
||||
include Liquidable
|
||||
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
|
||||
|
||||
@@ -77,7 +77,7 @@ class MailPresenter < SimpleDelegator
|
||||
mail.attachments.map do |attachment|
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new(attachment.body.to_s),
|
||||
filename: attachment.filename.presence || "attachment_#{SecureRandom.hex(4)}",
|
||||
filename: attachment.filename,
|
||||
content_type: attachment.content_type
|
||||
)
|
||||
{ original: attachment, blob: blob }
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
class Line::IncomingMessageService
|
||||
include ::FileTypeHelper
|
||||
pattr_initialize [:inbox!, :params!]
|
||||
LINE_STICKER_IMAGE_URL = 'https://stickershop.line-scdn.net/stickershop/v1/sticker/%s/android/sticker.png'.freeze
|
||||
LINE_STICKER_IMAGE_URL = 'https://stickershop.line-scdn.net/stickershop/v1/sticker/%s/iphone/sticker.png'.freeze
|
||||
|
||||
def perform
|
||||
# probably test events
|
||||
|
||||
@@ -34,11 +34,22 @@ class SearchService
|
||||
end
|
||||
|
||||
def filter_messages
|
||||
@messages = current_account.messages.where(inbox_id: accessable_inbox_ids)
|
||||
.where('messages.content ILIKE :search', search: "%#{search_query}%")
|
||||
.where('created_at >= ?', 3.months.ago)
|
||||
.reorder('created_at DESC')
|
||||
.limit(10)
|
||||
@messages = if SearchConfig.enabled?
|
||||
Message.search(
|
||||
search_query, where: {
|
||||
inbox_id: accessable_inbox_ids,
|
||||
account_id: current_account.id
|
||||
}, order: { created_at: :desc }, limit: 20
|
||||
)
|
||||
else
|
||||
current_account
|
||||
.messages
|
||||
.where(inbox_id: accessable_inbox_ids)
|
||||
.where('messages.content ILIKE :search', search: "%#{search_query}%")
|
||||
.where('created_at >= ?', 3.months.ago)
|
||||
.reorder('created_at DESC')
|
||||
.limit(10)
|
||||
end
|
||||
end
|
||||
|
||||
def filter_contacts
|
||||
|
||||
@@ -27,12 +27,6 @@ if ENV.fetch('SENTRY_DSN', false).present?
|
||||
require 'sentry-sidekiq'
|
||||
end
|
||||
|
||||
# heroku autoscaling
|
||||
if ENV.fetch('JUDOSCALE_URL', false).present?
|
||||
require 'judoscale-rails'
|
||||
require 'judoscale-sidekiq'
|
||||
end
|
||||
|
||||
module Chatwoot
|
||||
class Application < Rails::Application
|
||||
# Initialize configuration defaults for originally generated Rails version.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
require 'rack-timeout'
|
||||
|
||||
# Reduce noise by filtering state=ready and state=completed which are logged at INFO level
|
||||
Rails.application.config.after_initialize do
|
||||
Rack::Timeout::Logger.level = Logger::ERROR
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
class SearchConfig
|
||||
class << self
|
||||
def enabled?
|
||||
opensearch_url.present?
|
||||
end
|
||||
|
||||
def setup!
|
||||
return unless enabled?
|
||||
|
||||
Searchkick.client_options = client_options
|
||||
end
|
||||
|
||||
def client_options
|
||||
{
|
||||
url: opensearch_url,
|
||||
transport_options: {
|
||||
request: { timeout: timeout }
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def opensearch_url
|
||||
ENV.fetch('OPENSEARCH_URL', nil)
|
||||
end
|
||||
|
||||
def timeout
|
||||
ENV.fetch('OPENSEARCH_TIMEOUT', 10).to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -58,7 +58,6 @@ test:
|
||||
staging:
|
||||
<<: *default_settings
|
||||
app_name: <%= ENV.fetch('NEW_RELIC_APP_NAME', 'Chatwoot') %> (Staging)
|
||||
monitor_mode: <%= ENV.fetch('NEW_RELIC_STAGING_MONITORING_ENABLED', false) %>
|
||||
|
||||
production:
|
||||
<<: *default_settings
|
||||
|
||||
+1
-1
@@ -219,7 +219,7 @@ Rails.application.routes.draw do
|
||||
resources :apps, only: [:index, :show]
|
||||
resource :captain, controller: 'captain', only: [] do
|
||||
collection do
|
||||
post :proxy
|
||||
get :sso_url
|
||||
end
|
||||
end
|
||||
resources :hooks, only: [:show, :create, :update, :destroy] do
|
||||
|
||||
+16
-16
@@ -8,21 +8,21 @@ internal_check_new_versions_job:
|
||||
class: 'Internal::CheckNewVersionsJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# executed At every 5th minute..
|
||||
trigger_scheduled_items_job:
|
||||
cron: '*/5 * * * *'
|
||||
class: 'TriggerScheduledItemsJob'
|
||||
queue: scheduled_jobs
|
||||
# # executed At every 5th minute..
|
||||
# trigger_scheduled_items_job:
|
||||
# cron: '*/5 * * * *'
|
||||
# class: 'TriggerScheduledItemsJob'
|
||||
# queue: scheduled_jobs
|
||||
|
||||
# executed At every minute..
|
||||
trigger_imap_email_inboxes_job:
|
||||
cron: '*/1 * * * *'
|
||||
class: 'Inboxes::FetchImapEmailInboxesJob'
|
||||
queue: scheduled_jobs
|
||||
# # executed At every minute..
|
||||
# trigger_imap_email_inboxes_job:
|
||||
# cron: '*/1 * * * *'
|
||||
# class: 'Inboxes::FetchImapEmailInboxesJob'
|
||||
# queue: scheduled_jobs
|
||||
|
||||
# executed daily at 2230 UTC
|
||||
# which is our lowest traffic time
|
||||
remove_stale_contact_inboxes_job.rb:
|
||||
cron: '30 22 * * *'
|
||||
class: 'Internal::RemoveStaleContactInboxesJob'
|
||||
queue: scheduled_jobs
|
||||
# # executed daily at 2230 UTC
|
||||
# # which is our lowest traffic time
|
||||
# remove_stale_contact_inboxes_job.rb:
|
||||
# cron: '30 22 * * *'
|
||||
# class: 'Internal::RemoveStaleContactInboxesJob'
|
||||
# queue: scheduled_jobs
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
- action_mailbox_routing
|
||||
- low
|
||||
- scheduled_jobs
|
||||
- searchkick
|
||||
- async_database_migration
|
||||
- active_storage_analysis
|
||||
- active_storage_purge
|
||||
|
||||
+4
-5
@@ -31,7 +31,6 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/captain": "0.0.3-alpha.4",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.1.1-next",
|
||||
"@chatwoot/utils": "^0.0.25",
|
||||
@@ -84,7 +83,7 @@
|
||||
"video.js": "7.18.1",
|
||||
"videojs-record": "4.5.0",
|
||||
"videojs-wavesurfer": "3.8.0",
|
||||
"vue": "^3.5.12",
|
||||
"vue": "^3.5.8",
|
||||
"vue-chartjs": "5.3.1",
|
||||
"vue-datepicker-next": "^1.0.3",
|
||||
"vue-dompurify-html": "^5.1.0",
|
||||
@@ -102,10 +101,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@egoist/tailwindcss-icons": "^1.8.1",
|
||||
"@iconify-json/ri": "^1.2.1",
|
||||
"@histoire/plugin-vue": "0.17.15",
|
||||
"@iconify-json/logos": "^1.2.3",
|
||||
"@iconify-json/lucide": "^1.2.11",
|
||||
"@iconify-json/ri": "^1.2.3",
|
||||
"@iconify-json/logos": "^1.2.0",
|
||||
"@iconify-json/lucide": "^1.2.10",
|
||||
"@size-limit/file": "^8.2.4",
|
||||
"@vitest/coverage-v8": "2.0.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
|
||||
Generated
+134
-484
File diff suppressed because it is too large
Load Diff
@@ -1,79 +1,65 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Captain Integrations API', type: :request do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:hook) do
|
||||
create(:integrations_hook, account: account, app_id: 'captain', settings: {
|
||||
access_token: SecureRandom.hex,
|
||||
account_email: Faker::Internet.email,
|
||||
assistant_id: '1',
|
||||
account_id: '1'
|
||||
})
|
||||
end
|
||||
let(:captain_api_url) { 'https://captain.example.com/' }
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
before do
|
||||
InstallationConfig.where(name: 'CAPTAIN_API_URL').first_or_create(value: captain_api_url)
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/integrations/captain/proxy' do
|
||||
describe 'GET /api/v1/accounts/{account.id}/integrations/captain/sso_url' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: { method: 'get', route: 'some_route' },
|
||||
as: :json
|
||||
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: {},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when valid request method and route' do
|
||||
let(:route) { 'some_route' }
|
||||
let(:method) { 'get' }
|
||||
it 'return unauthorized if agent' do
|
||||
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: {},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
it 'proxies the request to Captain API' do
|
||||
stub_request(:get, "#{captain_api_url}api/accounts/#{hook.settings['account_id']}/#{route}")
|
||||
.with(headers: {
|
||||
'X-User-Email' => hook.settings['account_email'],
|
||||
'X-User-Token' => hook.settings['access_token'],
|
||||
'Content-Type' => 'application/json'
|
||||
})
|
||||
.to_return(status: 200, body: 'Success', headers: {})
|
||||
|
||||
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: { method: method, route: route },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to eq('Success')
|
||||
end
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
context 'when HTTP method is invalid' do
|
||||
it 'returns unprocessable entity' do
|
||||
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: { method: 'invalid', route: 'some_route', body: { some: 'data' } },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
it 'returns 404 if hook is not available' do
|
||||
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: {},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:internal_server_error)
|
||||
end
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
context 'when the hook is not found' do
|
||||
before { hook.destroy }
|
||||
it 'returns sso url if hook is available' do
|
||||
InstallationConfig.where(name: 'CAPTAIN_APP_URL').first_or_create(value: 'https://app.chatwoot.com')
|
||||
|
||||
it 'returns not found' do
|
||||
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: { method: 'get', route: 'some_route' },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
hook = create(:integrations_hook, account: account, app_id: 'captain', settings: {
|
||||
access_token: SecureRandom.hex,
|
||||
account_email: Faker::Internet.email,
|
||||
account_id: '1',
|
||||
assistant_id: '1',
|
||||
inbox_ids: '1'
|
||||
})
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: {},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
data = response.parsed_body
|
||||
params_string = "token=#{URI.encode_www_form_component(hook['settings']['access_token'])}" \
|
||||
"&email=#{URI.encode_www_form_component(hook['settings']['account_email'])}" \
|
||||
"&account_id=#{URI.encode_www_form_component(hook['settings']['account_id'])}"
|
||||
|
||||
sso_url = "https://app.chatwoot.com/sso?#{params_string}"
|
||||
expect(data['sso_url']).to eq(sso_url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
From: test@gmail.com
|
||||
Date: Thu, 4 May 2023 10:35:52 +0530
|
||||
Message-ID: <6215d536e0484_10bc6191402183@tejaswinis-MacBook-Pro.local.mail>
|
||||
Subject: multiple attachments
|
||||
To: test@outlook.com
|
||||
Content-Type: multipart/mixed; boundary="0000000000002488f405fad721cc"
|
||||
|
||||
--0000000000002488f405fad721cc
|
||||
Content-Type: multipart/alternative; boundary="0000000000002488f205fad721ca"
|
||||
|
||||
--0000000000002488f205fad721ca
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
Hi people!
|
||||
|
||||
We are excited to inform you that we have recently released some new
|
||||
features and several updates to our platform. These features and updates
|
||||
are designed to enhance your experience and make your trading journey
|
||||
seamless and efficient.
|
||||
|
||||
|
||||
|
||||
> Okay noted
|
||||
|
||||
|
||||
--0000000000002488f205fad721ca--
|
||||
--0000000000002488f405fad721cc
|
||||
Content-Type: image/png; name=""
|
||||
Content-Disposition: attachment; filename=""
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <f_lh8nwk8l3>
|
||||
X-Attachment-Id: f_lh8nwk8l3
|
||||
|
||||
|
||||
--0000000000002488f405fad721cc
|
||||
Content-Type: image/png; name=""
|
||||
Content-Disposition: attachment; filename=""
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <f_lh8nwk8l2>
|
||||
X-Attachment-Id: f_lh8nwk8l2
|
||||
|
||||
|
||||
--0000000000002488f405fad721cc--
|
||||
@@ -43,22 +43,6 @@ RSpec.describe Imap::ImapMailbox do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the email has attachments with no filename' do
|
||||
let(:inbound_mail) { create_inbound_email_from_fixture('attachments_without_filename.eml') }
|
||||
|
||||
it 'creates a conversation and a message with properly named attachments' do
|
||||
expect do
|
||||
class_instance.process(inbound_mail.mail, channel)
|
||||
end.to change(Conversation, :count).by(1)
|
||||
|
||||
last_message = conversation.messages.last
|
||||
expect(last_message.attachments.count).to be 2
|
||||
|
||||
filenames = last_message.attachments.map(&:file).map { |file| file.blob.filename.to_s }
|
||||
expect(filenames.all? { |filename| filename.present? && filename.start_with?('attachment_') }).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the email has 15 or more attachments' do
|
||||
let(:inbound_mail) { create_inbound_email_from_fixture('multiple_attachments.eml') }
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ describe Line::IncomingMessageService do
|
||||
described_class.new(inbox: line_channel.inbox, params: sticker_params).perform
|
||||
expect(line_channel.inbox.conversations).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('LINE Test')
|
||||
expect(line_channel.inbox.messages.first.content).to eq('')
|
||||
expect(line_channel.inbox.messages.first.content).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -117,13 +117,6 @@ const tailwindConfig = {
|
||||
width: 2,
|
||||
height: 12,
|
||||
},
|
||||
captain: {
|
||||
body: `<path d="M150.485 213.282C150.485 200.856 160.559 190.782 172.985 190.782C185.411 190.782 195.485 200.856 195.485 213.282V265.282C195.485 277.709 185.411 287.782 172.985 287.782C160.559 287.782 150.485 277.709 150.485 265.282V213.282Z" fill="currentColor"/>
|
||||
<path d="M222.485 213.282C222.485 200.856 232.559 190.782 244.985 190.782C257.411 190.782 267.485 200.856 267.485 213.282V265.282C267.485 277.709 257.411 287.782 244.985 287.782C232.559 287.782 222.485 277.709 222.485 265.282V213.282Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M412.222 109.961C317.808 96.6217 240.845 96.0953 144.309 109.902C119.908 113.392 103.762 115.751 91.4521 119.354C80.0374 122.694 73.5457 126.678 68.1762 132.687C57.0576 145.13 55.592 159.204 54.0765 208.287C52.587 256.526 55.5372 299.759 61.1249 348.403C64.1025 374.324 66.1515 391.817 69.4229 405.117C72.526 417.732 76.2792 424.515 81.4954 429.708C86.7533 434.942 93.4917 438.633 105.859 441.629C118.94 444.797 136.104 446.713 161.613 449.5C244.114 458.514 305.869 458.469 388.677 449.548C414.495 446.767 431.939 444.849 445.216 441.702C457.83 438.712 464.612 435.047 469.797 429.962C474.873 424.985 478.752 418.118 482.116 404.874C485.626 391.056 488.014 372.772 491.47 345.913C497.636 297.99 502.076 255.903 502.248 209.798C502.433 160.503 501.426 146.477 490.181 133.468C484.75 127.185 478.148 123.053 466.473 119.612C453.865 115.897 437.283 113.502 412.222 109.961ZM138.414 68.5711C238.977 54.1882 319.888 54.7514 418.047 68.6199L419.483 68.8227C442.724 72.1054 462.359 74.8786 478.244 79.5601C495.387 84.6124 509.724 92.2821 521.706 106.145C544.308 132.295 544.161 163.321 543.965 204.542C543.956 206.327 543.948 208.131 543.941 209.954C543.758 258.703 539.048 302.844 532.821 351.247L532.656 352.528C529.407 377.787 526.729 398.602 522.522 415.166C518.098 432.584 511.485 447.517 498.968 459.792C486.56 471.959 471.897 478.282 454.819 482.33C438.691 486.153 418.624 488.314 394.436 490.919L393.136 491.059C307.385 500.297 242.618 500.349 157.091 491.004L155.772 490.86C131.921 488.255 112.062 486.086 96.056 482.209C79.0408 478.087 64.4759 471.637 52.1005 459.316C39.6835 446.955 33.1618 432.265 28.94 415.102C24.9582 398.915 22.6435 378.759 19.8561 354.488L19.7052 353.174C13.9746 303.287 10.8315 257.908 12.4035 206.997C12.4606 205.15 12.5151 203.323 12.5691 201.516C13.7911 160.603 14.7077 129.914 37.1055 104.847C48.989 91.5477 63.035 84.1731 79.7563 79.2794C95.2643 74.7408 114.386 72.0068 137.018 68.7707C137.482 68.7044 137.948 68.6379 138.414 68.5711Z" fill="currentColor"/>`,
|
||||
width: 556,
|
||||
height: 556,
|
||||
},
|
||||
},
|
||||
},
|
||||
...getIconCollections(['lucide', 'logos', 'ri']),
|
||||
|
||||
Reference in New Issue
Block a user