Merge branch 'develop' into fix/conversation-tabs-limit
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
class Messages::MessageBuilder
|
||||
include ::FileTypeHelper
|
||||
include ::EmailHelper
|
||||
include ::DataHelper
|
||||
|
||||
attr_reader :message
|
||||
|
||||
def initialize(user, conversation, params)
|
||||
@@ -38,30 +41,12 @@ class Messages::MessageBuilder
|
||||
params = convert_to_hash(@params)
|
||||
content_attributes = params.fetch(:content_attributes, {})
|
||||
|
||||
return parse_json(content_attributes) if content_attributes.is_a?(String)
|
||||
return safe_parse_json(content_attributes) if content_attributes.is_a?(String)
|
||||
return content_attributes if content_attributes.is_a?(Hash)
|
||||
|
||||
{}
|
||||
end
|
||||
|
||||
# Converts the given object to a hash.
|
||||
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
|
||||
# Otherwise, returns the object as-is.
|
||||
def convert_to_hash(obj)
|
||||
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
|
||||
|
||||
obj
|
||||
end
|
||||
|
||||
# Attempts to parse a string as JSON.
|
||||
# If successful, returns the parsed hash with symbolized names.
|
||||
# If unsuccessful, returns nil.
|
||||
def parse_json(content)
|
||||
JSON.parse(content, symbolize_names: true)
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
def process_attachments
|
||||
return if @attachments.blank?
|
||||
|
||||
@@ -110,12 +95,6 @@ class Messages::MessageBuilder
|
||||
email_string.gsub(/\s+/, '').split(',')
|
||||
end
|
||||
|
||||
def validate_email_addresses(all_emails)
|
||||
all_emails&.each do |email|
|
||||
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
||||
end
|
||||
end
|
||||
|
||||
def message_type
|
||||
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
||||
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
||||
@@ -178,14 +157,17 @@ class Messages::MessageBuilder
|
||||
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
|
||||
normalized_content = normalize_email_body(@message.content)
|
||||
|
||||
# Process liquid templates in normalized content with code block protection
|
||||
processed_content = process_liquid_in_email_body(normalized_content)
|
||||
|
||||
# Use custom HTML content if provided, otherwise generate from message content
|
||||
email_attributes[:html_content] = if custom_email_content_provided?
|
||||
build_custom_html_content
|
||||
else
|
||||
build_html_content(normalized_content)
|
||||
build_html_content(processed_content)
|
||||
end
|
||||
|
||||
email_attributes[:text_content] = build_text_content(normalized_content)
|
||||
email_attributes[:text_content] = build_text_content(processed_content)
|
||||
email_attributes
|
||||
end
|
||||
|
||||
@@ -204,22 +186,6 @@ class Messages::MessageBuilder
|
||||
text_content
|
||||
end
|
||||
|
||||
def ensure_indifferent_access(hash)
|
||||
return {} if hash.blank?
|
||||
|
||||
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
|
||||
end
|
||||
|
||||
def normalize_email_body(content)
|
||||
content.to_s.gsub("\r\n", "\n")
|
||||
end
|
||||
|
||||
def render_email_html(content)
|
||||
return '' if content.blank?
|
||||
|
||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||
end
|
||||
|
||||
def custom_email_content_provided?
|
||||
@params[:email_html_content].present?
|
||||
end
|
||||
@@ -232,4 +198,27 @@ class Messages::MessageBuilder
|
||||
|
||||
html_content
|
||||
end
|
||||
|
||||
# Liquid processing methods for email content
|
||||
def process_liquid_in_email_body(content)
|
||||
return content if content.blank?
|
||||
return content unless should_process_liquid?
|
||||
|
||||
# Protect code blocks from liquid processing
|
||||
modified_content = modified_liquid_content(content)
|
||||
template = Liquid::Template.parse(modified_content)
|
||||
template.render(drops_with_sender)
|
||||
rescue Liquid::Error
|
||||
content
|
||||
end
|
||||
|
||||
def should_process_liquid?
|
||||
@message_type == 'outgoing' || @message_type == 'template'
|
||||
end
|
||||
|
||||
def drops_with_sender
|
||||
message_drops(@conversation).merge({
|
||||
'agent' => UserDrop.new(sender)
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,9 +22,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
def edit; end
|
||||
|
||||
def create
|
||||
@article = @portal.articles.create!(article_params)
|
||||
params_with_defaults = article_params
|
||||
params_with_defaults[:status] ||= :draft
|
||||
@article = @portal.articles.create!(params_with_defaults)
|
||||
@article.associate_root_article(article_params[:associated_article_id])
|
||||
@article.draft!
|
||||
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Provides utility methods for data transformation, hash manipulation, and JSON parsing.
|
||||
# This module contains helper methods for converting between different data types,
|
||||
# normalizing hashes, and safely handling JSON operations.
|
||||
module DataHelper
|
||||
# Ensures a hash supports indifferent access (string or symbol keys).
|
||||
# Returns an empty hash if the input is blank.
|
||||
def ensure_indifferent_access(hash)
|
||||
return {} if hash.blank?
|
||||
|
||||
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
|
||||
end
|
||||
|
||||
def convert_to_hash(obj)
|
||||
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
|
||||
|
||||
obj
|
||||
end
|
||||
|
||||
def safe_parse_json(content)
|
||||
JSON.parse(content, symbolize_names: true)
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,19 @@ module EmailHelper
|
||||
domain.split('.').first
|
||||
end
|
||||
|
||||
def render_email_html(content)
|
||||
return '' if content.blank?
|
||||
|
||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||
end
|
||||
|
||||
# Raise a standard error if any email address is invalid
|
||||
def validate_email_addresses(emails_to_test)
|
||||
emails_to_test&.each do |email|
|
||||
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
||||
end
|
||||
end
|
||||
|
||||
# ref: https://www.rfc-editor.org/rfc/rfc5233.html
|
||||
# This is not a mandatory requirement for email addresses, but it is a common practice.
|
||||
# john+test@xyc.com is the same as john@xyc.com
|
||||
@@ -21,6 +34,10 @@ module EmailHelper
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_email_body(content)
|
||||
content.to_s.gsub("\r\n", "\n")
|
||||
end
|
||||
|
||||
def modified_liquid_content(email)
|
||||
# This regex is used to match the code blocks in the content
|
||||
# We don't want to process liquid in code blocks
|
||||
@@ -29,7 +46,10 @@ module EmailHelper
|
||||
|
||||
def message_drops(conversation)
|
||||
{
|
||||
'contact' => ContactDrop.new(conversation.contact)
|
||||
'contact' => ContactDrop.new(conversation.contact),
|
||||
'conversation' => ConversationDrop.new(conversation),
|
||||
'inbox' => InboxDrop.new(conversation.inbox),
|
||||
'account' => AccountDrop.new(conversation.account)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,6 @@ import { OnClickOutside } from '@vueuse/components';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import BackButton from 'dashboard/components/widgets/BackButton.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
@@ -117,58 +116,57 @@ const handleCreateAssistant = () => {
|
||||
<div
|
||||
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
||||
>
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="flex gap-3 items-center">
|
||||
<BackButton v-if="backUrl" :back-url="backUrl" />
|
||||
<slot name="headerTitle">
|
||||
<div v-if="showAssistantSwitcher" class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
v-if="activeAssistantName"
|
||||
class="text-xl font-medium truncate text-n-slate-12"
|
||||
<div v-if="showAssistantSwitcher" class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="activeAssistantName"
|
||||
class="text-xl font-medium truncate text-n-slate-12"
|
||||
>
|
||||
{{ activeAssistantName }}
|
||||
</span>
|
||||
<div v-if="activeAssistantName" class="relative group">
|
||||
<OnClickOutside
|
||||
@trigger="showAssistantSwitcherDropdown = false"
|
||||
>
|
||||
{{ activeAssistantName }}
|
||||
</span>
|
||||
<div v-if="activeAssistantName" class="relative group">
|
||||
<OnClickOutside
|
||||
@trigger="showAssistantSwitcherDropdown = false"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
|
||||
@click="toggleAssistantSwitcher"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
|
||||
@click="toggleAssistantSwitcher"
|
||||
/>
|
||||
|
||||
<AssistantSwitcher
|
||||
v-if="showAssistantSwitcherDropdown"
|
||||
class="absolute ltr:left-0 rtl:right-0 top-9"
|
||||
@close="showAssistantSwitcherDropdown = false"
|
||||
@create-assistant="handleCreateAssistant"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="activeAssistantName"
|
||||
icon="i-lucide-chevron-right"
|
||||
class="size-6 text-n-slate-11"
|
||||
/>
|
||||
<span class="text-xl font-medium text-n-slate-11">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<AssistantSwitcher
|
||||
v-if="showAssistantSwitcherDropdown"
|
||||
class="absolute ltr:left-0 rtl:right-0 top-9"
|
||||
@close="showAssistantSwitcherDropdown = false"
|
||||
@create-assistant="handleCreateAssistant"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-xl font-medium text-n-slate-12">
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
v-if="showAssistantSwitcher && headerTitle"
|
||||
class="w-0.5 h-4 rounded-2xl bg-n-weak"
|
||||
/>
|
||||
<span
|
||||
v-if="headerTitle"
|
||||
class="text-xl font-medium text-n-slate-12"
|
||||
>
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
</slot>
|
||||
<div
|
||||
v-if="!isEmpty && showKnowMore"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
|
||||
<slot name="knowMore" />
|
||||
<div
|
||||
v-if="!isEmpty && showKnowMore"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
|
||||
<slot name="knowMore" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -75,9 +75,9 @@ const sendMessage = async () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col h-full rounded-lg p-4 border border-n-slate-4 text-n-slate-11"
|
||||
class="flex flex-col h-full rounded-xl border py-6 border-n-weak text-n-slate-11"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<div class="mb-8 px-6">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<h3 class="text-lg font-medium">
|
||||
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
|
||||
@@ -85,6 +85,7 @@ const sendMessage = async () => {
|
||||
<NextButton
|
||||
ghost
|
||||
sm
|
||||
slate
|
||||
icon="i-lucide-rotate-ccw"
|
||||
@click="resetConversation"
|
||||
/>
|
||||
@@ -97,11 +98,11 @@ const sendMessage = async () => {
|
||||
<MessageList :messages="messages" :is-loading="isLoading" />
|
||||
|
||||
<div
|
||||
class="flex items-center bg-n-solid-1 outline outline-n-container rounded-lg p-3"
|
||||
class="flex items-center mx-6 bg-n-background outline outline-1 outline-n-weak rounded-xl p-3"
|
||||
>
|
||||
<input
|
||||
v-model="newMessage"
|
||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0"
|
||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
|
||||
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
|
||||
@@ -35,8 +35,8 @@ const getAvatarName = sender =>
|
||||
|
||||
const getMessageStyle = sender =>
|
||||
isUserMessage(sender)
|
||||
? 'bg-n-strong text-n-white'
|
||||
: 'bg-n-solid-iris text-n-slate-12';
|
||||
? 'bg-n-solid-blue text-n-slate-12 rounded-br-sm rounded-bl-xl rounded-t-xl'
|
||||
: 'bg-n-solid-iris text-n-slate-12 rounded-bl-sm rounded-br-xl rounded-t-xl';
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
@@ -49,7 +49,10 @@ watch(() => props.messages.length, scrollToBottom);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="messageContainer" class="flex-1 overflow-y-auto mb-4 space-y-2">
|
||||
<div
|
||||
ref="messageContainer"
|
||||
class="flex-1 overflow-y-auto mb-4 px-6 space-y-6"
|
||||
>
|
||||
<div
|
||||
v-for="(message, index) in messages"
|
||||
:key="index"
|
||||
@@ -57,15 +60,20 @@ watch(() => props.messages.length, scrollToBottom);
|
||||
:class="getMessageAlignment(message.sender)"
|
||||
>
|
||||
<div
|
||||
class="flex items-start gap-1.5"
|
||||
class="flex items-end gap-1.5 max-w-[90%] md:max-w-[60%]"
|
||||
:class="getMessageDirection(message.sender)"
|
||||
>
|
||||
<Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" />
|
||||
<Avatar
|
||||
:name="getAvatarName(message.sender)"
|
||||
rounded-full
|
||||
:size="24"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<div
|
||||
class="max-w-[80%] rounded-lg p-3 text-sm"
|
||||
class="px-4 py-3 text-sm [overflow-wrap:break-word]"
|
||||
:class="getMessageStyle(message.sender)"
|
||||
>
|
||||
<div class="break-words" v-html="formatMessage(message.content)" />
|
||||
<div v-html="formatMessage(message.content)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,15 @@ import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
const props = defineProps({
|
||||
menuItems: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
validator: value => {
|
||||
return value.every(item => item.action && item.value && item.label);
|
||||
},
|
||||
},
|
||||
menuSections: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
thumbnailSize: {
|
||||
type: Number,
|
||||
default: 20,
|
||||
@@ -42,19 +46,62 @@ const { t } = useI18n();
|
||||
const searchInput = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const filteredMenuItems = computed(() => {
|
||||
if (!searchQuery.value) return props.menuItems;
|
||||
const hasSections = computed(() => props.menuSections.length > 0);
|
||||
|
||||
return props.menuItems.filter(item =>
|
||||
const flattenedMenuItems = computed(() => {
|
||||
if (!hasSections.value) {
|
||||
return props.menuItems;
|
||||
}
|
||||
|
||||
return props.menuSections.flatMap(section => section.items || []);
|
||||
});
|
||||
|
||||
const filteredMenuItems = computed(() => {
|
||||
if (!searchQuery.value) return flattenedMenuItems.value;
|
||||
|
||||
return flattenedMenuItems.value.filter(item =>
|
||||
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const filteredMenuSections = computed(() => {
|
||||
if (!hasSections.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!searchQuery.value) {
|
||||
return props.menuSections;
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
|
||||
return props.menuSections
|
||||
.map(section => {
|
||||
const filteredItems = (section.items || []).filter(item =>
|
||||
item.label.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
return {
|
||||
...section,
|
||||
items: filteredItems,
|
||||
};
|
||||
})
|
||||
.filter(section => section.items.length > 0);
|
||||
});
|
||||
|
||||
const handleAction = item => {
|
||||
const { action, value, ...rest } = item;
|
||||
emit('action', { action, value, ...rest });
|
||||
};
|
||||
|
||||
const shouldShowEmptyState = computed(() => {
|
||||
if (hasSections.value) {
|
||||
return filteredMenuSections.value.length === 0;
|
||||
}
|
||||
|
||||
return filteredMenuItems.value.length === 0;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (searchInput.value && props.showSearch) {
|
||||
searchInput.value.focus();
|
||||
@@ -64,54 +111,122 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
|
||||
:class="{
|
||||
'pt-2': !showSearch,
|
||||
}"
|
||||
>
|
||||
<div v-if="showSearch" class="relative">
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-for="(item, index) in filteredMenuItems"
|
||||
:key="index"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||
'text-n-ruby-11': item.action === 'delete',
|
||||
'text-n-slate-12': item.action !== 'delete',
|
||||
}"
|
||||
:disabled="item.disabled"
|
||||
@click="handleAction(item)"
|
||||
>
|
||||
<slot name="thumbnail" :item="item">
|
||||
<Avatar
|
||||
v-if="item.thumbnail"
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
/>
|
||||
</slot>
|
||||
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="filteredMenuItems.length === 0"
|
||||
v-if="showSearch"
|
||||
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
|
||||
>
|
||||
<div class="relative">
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="hasSections">
|
||||
<div
|
||||
v-for="(section, sectionIndex) in filteredMenuSections"
|
||||
:key="section.title || sectionIndex"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<p
|
||||
v-if="section.title"
|
||||
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
|
||||
>
|
||||
{{ section.title }}
|
||||
</p>
|
||||
<button
|
||||
v-for="(item, itemIndex) in section.items"
|
||||
:key="item.value || itemIndex"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||
'text-n-ruby-11': item.action === 'delete',
|
||||
'text-n-slate-12': item.action !== 'delete',
|
||||
}"
|
||||
:disabled="item.disabled"
|
||||
@click="handleAction(item)"
|
||||
>
|
||||
<slot name="thumbnail" :item="item">
|
||||
<Avatar
|
||||
v-if="item.thumbnail"
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
/>
|
||||
</slot>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="sectionIndex < filteredMenuSections.length - 1"
|
||||
class="h-px bg-n-alpha-2 mx-2 my-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="(item, index) in filteredMenuItems"
|
||||
:key="index"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||
'text-n-ruby-11': item.action === 'delete',
|
||||
'text-n-slate-12': item.action !== 'delete',
|
||||
}"
|
||||
:disabled="item.disabled"
|
||||
@click="handleAction(item)"
|
||||
>
|
||||
<slot name="thumbnail" :item="item">
|
||||
<Avatar
|
||||
v-if="item.thumbnail"
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
/>
|
||||
</slot>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<div
|
||||
v-if="shouldShowEmptyState"
|
||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
||||
>
|
||||
{{
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'dashboard/constants/automation';
|
||||
|
||||
/**
|
||||
* This is a shared composables that holds utilites used to build dropdown and file options
|
||||
* This is a shared composables that holds utilities used to build dropdown and file options
|
||||
* @returns {Object} An object containing various automation-related functions and computed properties.
|
||||
*/
|
||||
export default function useAutomationValues() {
|
||||
|
||||
@@ -21,7 +21,7 @@ export const initializeAudioAlerts = user => {
|
||||
enable_audio_alerts: audioAlertType,
|
||||
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
|
||||
notification_tone: audioAlertTone,
|
||||
// UI Settings can be undefined initally as we don't send the
|
||||
// UI Settings can be undefined initially as we don't send the
|
||||
// entire payload for the user during the signup process.
|
||||
} = uiSettings || {};
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_14_DAYS": "Last 14 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"THIS_MONTH": "This month",
|
||||
"LAST_MONTH": "Last month",
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
"LAST_YEAR": "Last year",
|
||||
|
||||
@@ -10,14 +10,14 @@ const assistantId = computed(() => Number(route.params.assistantId));
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:header-title="$t('CAPTAIN.PLAYGROUND.HEADER')"
|
||||
show-assistant-switcher
|
||||
:show-pagination-footer="false"
|
||||
:show-know-more="false"
|
||||
class="h-full"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col h-full">
|
||||
<AssistantPlayground :assistant-id="assistantId" />
|
||||
<AssistantPlayground :assistant-id="assistantId" class="bg-n-solid-1" />
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
|
||||
@@ -104,7 +104,6 @@ const handleDeleteSuccess = () => {
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:header-title="$t('CAPTAIN.ASSISTANTS.SETTINGS.HEADER')"
|
||||
:is-fetching="isFetching"
|
||||
:show-pagination-footer="false"
|
||||
:show-know-more="false"
|
||||
|
||||
@@ -195,7 +195,10 @@ const navigateToPendingFAQs = () => {
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
store.dispatch('captainResponses/fetchPendingCount', selectedAssistantId);
|
||||
store.dispatch(
|
||||
'captainResponses/fetchPendingCount',
|
||||
selectedAssistantId.value
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ const tooltip = useHeatmapTooltip();
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<div
|
||||
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr] min-h-72"
|
||||
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr]"
|
||||
>
|
||||
<template v-if="isLoading">
|
||||
<div class="grid gap-[5px] flex-shrink-0">
|
||||
|
||||
+105
-57
@@ -1,15 +1,18 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { onMounted, ref, computed, watch } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import MetricCard from '../overview/MetricCard.vue';
|
||||
import BaseHeatmap from './BaseHeatmap.vue';
|
||||
import HeatmapDateRangeSelector from './HeatmapDateRangeSelector.vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||
import differenceInCalendarDays from 'date-fns/differenceInCalendarDays';
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import format from 'date-fns/format';
|
||||
import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import startOfMonth from 'date-fns/startOfMonth';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import format from 'date-fns/format';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -57,27 +60,33 @@ const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||
const heatmapData = useMapGetter(props.storeGetter);
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
||||
value: 6,
|
||||
},
|
||||
{
|
||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_14_DAYS'),
|
||||
value: 13,
|
||||
},
|
||||
{
|
||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
|
||||
value: 29,
|
||||
},
|
||||
];
|
||||
|
||||
const selectedDays = ref(6);
|
||||
const selectedFrom = ref(null);
|
||||
const selectedTo = ref(null);
|
||||
const selectedDaysBefore = ref(null);
|
||||
const selectedInbox = ref(null);
|
||||
const isMonthFilter = ref(false);
|
||||
const currentMonthOffset = ref(0);
|
||||
|
||||
const selectedDayFilter = computed(() =>
|
||||
menuItems.find(menuItem => menuItem.value === selectedDays.value)
|
||||
);
|
||||
const selectedRange = computed(() => {
|
||||
if (!selectedFrom.value || !selectedTo.value) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
from: selectedFrom.value,
|
||||
to: selectedTo.value,
|
||||
};
|
||||
});
|
||||
|
||||
const numberOfRows = computed(() => {
|
||||
if (!selectedRange.value) {
|
||||
return 0;
|
||||
}
|
||||
const dateDifference = differenceInCalendarDays(
|
||||
selectedRange.value.to,
|
||||
selectedRange.value.from
|
||||
);
|
||||
return dateDifference + 1;
|
||||
});
|
||||
|
||||
const inboxMenuItems = computed(() => {
|
||||
return [
|
||||
@@ -105,13 +114,42 @@ const selectedInboxFilter = computed(() => {
|
||||
|
||||
const isLoading = computed(() => uiFlags.value[props.uiFlagKey]);
|
||||
|
||||
// Keeps relative presets (last 7 days / this month) aligned with "now" during live refreshes.
|
||||
const resolveActiveRange = () => {
|
||||
if (isMonthFilter.value && currentMonthOffset.value === 0) {
|
||||
const now = new Date();
|
||||
const monthStart = startOfMonth(now);
|
||||
return {
|
||||
from: startOfDay(monthStart),
|
||||
to: endOfDay(now),
|
||||
};
|
||||
}
|
||||
|
||||
if (!isMonthFilter.value && selectedDaysBefore.value !== null) {
|
||||
const to = endOfDay(new Date());
|
||||
return {
|
||||
from: startOfDay(subDays(to, Number(selectedDaysBefore.value))),
|
||||
to,
|
||||
};
|
||||
}
|
||||
|
||||
return selectedRange.value;
|
||||
};
|
||||
|
||||
const downloadHeatmapData = () => {
|
||||
const to = endOfDay(new Date());
|
||||
const range = resolveActiveRange();
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { to } = range;
|
||||
const shouldUseBackendDownload =
|
||||
!isMonthFilter.value && !selectedInbox.value && props.downloadAction;
|
||||
|
||||
// If no inbox is selected and download action exists, use backend endpoint
|
||||
if (!selectedInbox.value && props.downloadAction) {
|
||||
if (shouldUseBackendDownload) {
|
||||
store.dispatch(props.downloadAction, {
|
||||
daysBefore: selectedDays.value,
|
||||
daysBefore: selectedDaysBefore.value,
|
||||
to: getUnixTime(to),
|
||||
});
|
||||
return;
|
||||
@@ -150,7 +188,6 @@ const downloadHeatmapData = () => {
|
||||
downloadCsvFile(fileName, csvContent);
|
||||
};
|
||||
|
||||
const [showDropdown, toggleDropdown] = useToggle();
|
||||
const [showInboxDropdown, toggleInboxDropdown] = useToggle();
|
||||
|
||||
const fetchHeatmapData = () => {
|
||||
@@ -158,8 +195,12 @@ const fetchHeatmapData = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
let to = endOfDay(new Date());
|
||||
let from = startOfDay(subDays(to, Number(selectedDays.value)));
|
||||
const range = resolveActiveRange();
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to } = range;
|
||||
|
||||
const params = {
|
||||
metric: props.metric,
|
||||
@@ -178,25 +219,43 @@ const fetchHeatmapData = () => {
|
||||
store.dispatch(props.storeAction, params);
|
||||
};
|
||||
|
||||
const handleAction = ({ value }) => {
|
||||
toggleDropdown(false);
|
||||
selectedDays.value = value;
|
||||
fetchHeatmapData();
|
||||
};
|
||||
|
||||
const handleInboxAction = ({ value }) => {
|
||||
toggleInboxDropdown(false);
|
||||
selectedInbox.value = value
|
||||
? inboxes.value.find(inbox => inbox.id === value)
|
||||
: null;
|
||||
fetchHeatmapData();
|
||||
};
|
||||
|
||||
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
|
||||
|
||||
const handleRangeTypeChange = type => {
|
||||
isMonthFilter.value = type === 'month';
|
||||
};
|
||||
|
||||
const handleMonthOffsetChange = offset => {
|
||||
currentMonthOffset.value = offset;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [selectedFrom.value, selectedTo.value],
|
||||
([from, to]) => {
|
||||
if (from && to) {
|
||||
fetchHeatmapData();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => selectedInbox.value,
|
||||
() => {
|
||||
if (selectedRange.value) {
|
||||
fetchHeatmapData();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('inboxes/get');
|
||||
fetchHeatmapData();
|
||||
startRefetching();
|
||||
});
|
||||
</script>
|
||||
@@ -205,25 +264,13 @@ onMounted(() => {
|
||||
<div class="flex flex-row flex-wrap max-w-full">
|
||||
<MetricCard :header="title">
|
||||
<template #control>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
:label="selectedDayFilter.label"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
:menu-items="menuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
<HeatmapDateRangeSelector
|
||||
v-model:from="selectedFrom"
|
||||
v-model:to="selectedTo"
|
||||
v-model:days-num="selectedDaysBefore"
|
||||
@range-type-change="handleRangeTypeChange"
|
||||
@month-offset-change="handleMonthOffsetChange"
|
||||
/>
|
||||
<div
|
||||
v-on-clickaway="() => toggleInboxDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
@@ -241,22 +288,23 @@ onMounted(() => {
|
||||
:menu-items="inboxMenuItems"
|
||||
show-search
|
||||
:search-placeholder="t('INBOX_REPORTS.SEARCH_INBOX')"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full min-w-[200px]"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full !min-w-56 max-w-56 max-h-96 overflow-y-auto"
|
||||
@action="handleInboxAction($event)"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
|
||||
icon="i-lucide-download"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="downloadHeatmapData"
|
||||
/>
|
||||
</template>
|
||||
<BaseHeatmap
|
||||
:heatmap-data="heatmapData"
|
||||
:number-of-rows="selectedDays + 1"
|
||||
:number-of-rows="numberOfRows"
|
||||
:is-loading="isLoading"
|
||||
:color-scheme="colorScheme"
|
||||
/>
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, defineModel } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import addMonths from 'date-fns/addMonths';
|
||||
import differenceInCalendarDays from 'date-fns/differenceInCalendarDays';
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import endOfMonth from 'date-fns/endOfMonth';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import startOfMonth from 'date-fns/startOfMonth';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const emit = defineEmits(['rangeTypeChange', 'monthOffsetChange']);
|
||||
|
||||
const fromModel = defineModel('from', { type: Date, default: null });
|
||||
const toModel = defineModel('to', { type: Date, default: null });
|
||||
const daysNumModel = defineModel('daysNum', { type: Number, default: null });
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const DATE_FILTER_TYPES = {
|
||||
DAY: 'day',
|
||||
MONTH: 'month',
|
||||
};
|
||||
|
||||
const DATE_FILTER_ACTION = 'select_date_range';
|
||||
|
||||
const dayMenuItemConfigs = computed(() => [
|
||||
{
|
||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
||||
value: 'last_7_days',
|
||||
action: DATE_FILTER_ACTION,
|
||||
type: DATE_FILTER_TYPES.DAY,
|
||||
daysBefore: 6,
|
||||
},
|
||||
{
|
||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_14_DAYS'),
|
||||
value: 'last_14_days',
|
||||
action: DATE_FILTER_ACTION,
|
||||
type: DATE_FILTER_TYPES.DAY,
|
||||
daysBefore: 13,
|
||||
},
|
||||
{
|
||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
|
||||
value: 'last_30_days',
|
||||
action: DATE_FILTER_ACTION,
|
||||
type: DATE_FILTER_TYPES.DAY,
|
||||
daysBefore: 29,
|
||||
},
|
||||
]);
|
||||
|
||||
const resolvedLocale = computed(
|
||||
() =>
|
||||
locale.value ||
|
||||
(typeof navigator !== 'undefined' ? navigator.language : 'en')
|
||||
);
|
||||
|
||||
const monthFormatter = computed(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(resolvedLocale.value, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
);
|
||||
|
||||
const monthMenuItemConfigs = computed(() => {
|
||||
const now = new Date();
|
||||
const offsets = [0, -1, -2];
|
||||
|
||||
return offsets.map(offset => ({
|
||||
label:
|
||||
offset === 0
|
||||
? t('REPORT.DATE_RANGE_OPTIONS.THIS_MONTH')
|
||||
: monthFormatter.value.format(addMonths(now, offset)),
|
||||
value: offset === 0 ? 'this_month' : `month_${offset}`,
|
||||
action: DATE_FILTER_ACTION,
|
||||
type: DATE_FILTER_TYPES.MONTH,
|
||||
monthOffset: offset,
|
||||
}));
|
||||
});
|
||||
|
||||
const selectedDateRangeValue = ref('');
|
||||
|
||||
const [showDropdown, toggleDropdown] = useToggle();
|
||||
const monthOffset = ref(0);
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const selectedValue = selectedDateRangeValue.value;
|
||||
return [...dayMenuItemConfigs.value, ...monthMenuItemConfigs.value].map(
|
||||
config => ({
|
||||
...config,
|
||||
isSelected: selectedValue === config.value,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
selectedDateRangeValue.value = menuItems.value[0]?.value || '';
|
||||
|
||||
const menuSections = computed(() => {
|
||||
const dayItems = menuItems.value.filter(
|
||||
item => item.type === DATE_FILTER_TYPES.DAY
|
||||
);
|
||||
const monthItems = menuItems.value.filter(
|
||||
item => item.type === DATE_FILTER_TYPES.MONTH
|
||||
);
|
||||
|
||||
return [{ items: dayItems }, { items: monthItems }].filter(
|
||||
section => section.items.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
const selectedConfig = computed(
|
||||
() =>
|
||||
menuItems.value.find(
|
||||
menuItem => menuItem.value === selectedDateRangeValue.value
|
||||
) || menuItems.value[0]
|
||||
);
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
const selectedItem = menuItems.value.find(
|
||||
item => item.value === selectedDateRangeValue.value
|
||||
);
|
||||
return selectedItem?.label || '';
|
||||
});
|
||||
|
||||
const computeRange = config => {
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.type === DATE_FILTER_TYPES.MONTH) {
|
||||
const now = new Date();
|
||||
const baseMonthStart = startOfMonth(addMonths(now, monthOffset.value));
|
||||
const from = startOfDay(baseMonthStart);
|
||||
const isCurrentMonth =
|
||||
config.value === 'this_month' && monthOffset.value === 0;
|
||||
const to = isCurrentMonth
|
||||
? endOfDay(now)
|
||||
: endOfDay(endOfMonth(baseMonthStart));
|
||||
const daysBefore = differenceInCalendarDays(to, from);
|
||||
return { from, to, daysBefore };
|
||||
}
|
||||
|
||||
const to = endOfDay(new Date());
|
||||
const from = startOfDay(subDays(to, Number(config.daysBefore)));
|
||||
return { from, to, daysBefore: Number(config.daysBefore) };
|
||||
};
|
||||
|
||||
const applySelection = config => {
|
||||
if (!config) return;
|
||||
|
||||
if (config.type === DATE_FILTER_TYPES.MONTH) {
|
||||
monthOffset.value = config.monthOffset || 0;
|
||||
} else {
|
||||
monthOffset.value = 0;
|
||||
}
|
||||
|
||||
const range = computeRange(config);
|
||||
if (!range) return;
|
||||
|
||||
const { from, to, daysBefore } = range;
|
||||
fromModel.value = from;
|
||||
toModel.value = to;
|
||||
daysNumModel.value = daysBefore;
|
||||
|
||||
emit('rangeTypeChange', config.type);
|
||||
emit('monthOffsetChange', monthOffset.value);
|
||||
};
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
toggleDropdown(false);
|
||||
if (action !== DATE_FILTER_ACTION) {
|
||||
return;
|
||||
}
|
||||
selectedDateRangeValue.value = value;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => selectedConfig.value,
|
||||
config => {
|
||||
applySelection(config);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
:label="selectedLabel"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
:menu-items="menuItems"
|
||||
:menu-sections="menuSections"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,7 +8,7 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
|
||||
def perform(entries)
|
||||
@entries = entries
|
||||
|
||||
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id)
|
||||
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: contact_instagram_id, ig_account_id: ig_account_id)
|
||||
with_lock(key) do
|
||||
process_entries(entries)
|
||||
end
|
||||
@@ -77,6 +77,23 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
|
||||
@entries&.first&.dig(:id)
|
||||
end
|
||||
|
||||
def contact_instagram_id
|
||||
entry = @entries&.first
|
||||
return nil unless entry
|
||||
|
||||
# Handle both messaging and standby arrays
|
||||
messaging = (entry[:messaging].presence || entry[:standby] || []).first
|
||||
return nil unless messaging
|
||||
|
||||
# For echo messages (outgoing from our account), use recipient's ID (the contact)
|
||||
# For incoming messages (from contact), use sender's ID (the contact)
|
||||
if messaging.dig(:message, :is_echo)
|
||||
messaging.dig(:recipient, :id)
|
||||
else
|
||||
messaging.dig(:sender, :id)
|
||||
end
|
||||
end
|
||||
|
||||
def sender_id
|
||||
@entries&.dig(0, :messaging, 0, :sender, :id)
|
||||
end
|
||||
|
||||
@@ -4,38 +4,21 @@ class ApplicationMailbox < ActionMailbox::Base
|
||||
# Last part is the regex for the UUID
|
||||
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
|
||||
REPLY_EMAIL_UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
|
||||
CONVERSATION_MESSAGE_ID_PATTERN = %r{conversation/([a-zA-Z0-9-]*?)/messages/(\d+?)@(\w+\.\w+)}
|
||||
|
||||
# routes as a reply to existing conversations
|
||||
# Route all emails to verified channels to the unified reply mailbox
|
||||
# The ConversationFinder will determine if it's a reply or new conversation
|
||||
routing(
|
||||
->(inbound_mail) { valid_to_address?(inbound_mail) && (reply_uuid_mail?(inbound_mail) || in_reply_to_mail?(inbound_mail)) } => :reply
|
||||
)
|
||||
|
||||
# routes as a new conversation in email channel
|
||||
routing(
|
||||
->(inbound_mail) { valid_to_address?(inbound_mail) && EmailChannelFinder.new(inbound_mail.mail).perform.present? } => :support
|
||||
lambda { |inbound_mail|
|
||||
valid_to_address?(inbound_mail) &&
|
||||
(reply_uuid_mail?(inbound_mail) || EmailChannelFinder.new(inbound_mail.mail).perform.present?)
|
||||
} => :reply
|
||||
)
|
||||
|
||||
# catchall
|
||||
routing(all: :default)
|
||||
|
||||
class << self
|
||||
# checks if follow this pattern then send it to reply_mailbox
|
||||
# <account/#{@account.id}/conversation/#{@conversation.uuid}@#{@account.inbound_email_domain}>
|
||||
def in_reply_to_mail?(inbound_mail)
|
||||
in_reply_to = inbound_mail.mail.in_reply_to
|
||||
|
||||
in_reply_to.present? && (
|
||||
in_reply_to_matches?(in_reply_to) || Message.exists?(source_id: in_reply_to)
|
||||
)
|
||||
end
|
||||
|
||||
def in_reply_to_matches?(in_reply_to)
|
||||
Array.wrap(in_reply_to).any? { it.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
|
||||
end
|
||||
|
||||
# checks if follow this pattern send it to reply_mailbox
|
||||
# reply+<conversation-uuid>@<mailer-domain.com>
|
||||
# checks if follows this pattern: reply+<conversation-uuid>@<mailer-domain.com>
|
||||
def reply_uuid_mail?(inbound_mail)
|
||||
inbound_mail.mail.to&.any? do |email|
|
||||
conversation_uuid = email.split('@')[0]
|
||||
|
||||
@@ -3,6 +3,8 @@ class Imap::ImapMailbox
|
||||
include IncomingEmailValidityHelper
|
||||
attr_accessor :channel, :account, :inbox, :conversation, :processed_mail
|
||||
|
||||
FALLBACK_CONVERSATION_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
|
||||
|
||||
def process(mail, channel)
|
||||
@inbound_mail = mail
|
||||
@channel = channel
|
||||
@@ -49,19 +51,32 @@ class Imap::ImapMailbox
|
||||
end
|
||||
|
||||
def find_conversation_by_reference_ids
|
||||
return if @inbound_mail.references.blank? && in_reply_to.present?
|
||||
return if @inbound_mail.references.blank?
|
||||
|
||||
message = find_message_by_references
|
||||
if message.present?
|
||||
conversation = @inbox.conversations.find_by(id: message.conversation_id)
|
||||
return conversation if conversation.present?
|
||||
end
|
||||
|
||||
return if message.nil?
|
||||
|
||||
@inbox.conversations.find(message.conversation_id)
|
||||
# FALLBACK_PATTERN use to find a conversation that is started by an agent (no incoming message yet)
|
||||
conversation_id = find_conversation_by_references
|
||||
@inbox.conversations.find_by(uuid: conversation_id) if conversation_id.present?
|
||||
end
|
||||
|
||||
def in_reply_to
|
||||
@processed_mail.in_reply_to
|
||||
end
|
||||
|
||||
def find_conversation_by_references
|
||||
references = Array.wrap(@inbound_mail.references)
|
||||
references.each do |message_id|
|
||||
match = FALLBACK_CONVERSATION_PATTERN.match(message_id)
|
||||
|
||||
return match[2] if match.present?
|
||||
end
|
||||
end
|
||||
|
||||
def find_message_by_references
|
||||
message_to_return = nil
|
||||
|
||||
|
||||
@@ -1,88 +1,38 @@
|
||||
class ReplyMailbox < ApplicationMailbox
|
||||
attr_accessor :conversation_uuid, :processed_mail
|
||||
attr_accessor :conversation, :processed_mail
|
||||
|
||||
# Last part is the regex for the UUID
|
||||
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
|
||||
EMAIL_PART_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
|
||||
|
||||
before_processing :conversation_uuid_from_to_address,
|
||||
:find_relative_conversation
|
||||
before_processing :find_conversation
|
||||
|
||||
def process
|
||||
return if @conversation.blank?
|
||||
# Return early if no conversation was found (e.g., notification emails, suspended accounts)
|
||||
return unless @conversation
|
||||
|
||||
decorate_mail
|
||||
create_message
|
||||
add_attachments_to_message
|
||||
# Wrap everything in a transaction to ensure atomicity
|
||||
# This prevents orphan conversations if message/attachment creation fails
|
||||
# and ensures idempotency on job retry (conversation won't be duplicated)
|
||||
ActiveRecord::Base.transaction do
|
||||
persist_conversation_if_needed
|
||||
decorate_mail
|
||||
create_message
|
||||
add_attachments_to_message
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_relative_conversation
|
||||
if @conversation_uuid
|
||||
find_conversation_with_uuid
|
||||
elsif mail.in_reply_to.present?
|
||||
find_conversation_with_in_reply_to
|
||||
end
|
||||
def find_conversation
|
||||
@conversation = Mailbox::ConversationFinder.new(mail).find
|
||||
# Log when email is rejected
|
||||
Rails.logger.info "Email #{mail.message_id} rejected - no conversation found" unless @conversation
|
||||
end
|
||||
|
||||
def conversation_uuid_from_to_address
|
||||
@mail = MailPresenter.new(mail)
|
||||
def persist_conversation_if_needed
|
||||
# Save the conversation if it's a new record (from NewConversationStrategy)
|
||||
# We persist here instead of in the strategy to maintain transaction integrity
|
||||
return unless @conversation.new_record?
|
||||
|
||||
return if @mail.mail_receiver.blank?
|
||||
|
||||
@mail.mail_receiver.each do |email|
|
||||
username = email.split('@')[0]
|
||||
match_result = username.match(ApplicationMailbox::REPLY_EMAIL_UUID_PATTERN)
|
||||
if match_result
|
||||
@conversation_uuid = match_result.captures
|
||||
break
|
||||
end
|
||||
end
|
||||
@conversation_uuid
|
||||
end
|
||||
|
||||
# find conversation uuid from below pattern
|
||||
# reply+<conversation-uuid>@<mailer-domain.com>
|
||||
def find_conversation_with_uuid
|
||||
@conversation = Conversation.find_by(uuid: conversation_uuid)
|
||||
validate_resource @conversation
|
||||
end
|
||||
|
||||
def find_conversation_by_uuid(match_result)
|
||||
@conversation_uuid = match_result.captures[0]
|
||||
|
||||
find_conversation_with_uuid
|
||||
end
|
||||
|
||||
def find_conversation_by_message_id(in_reply_to)
|
||||
@message = Message.find_by(source_id: in_reply_to)
|
||||
@conversation = @message.conversation if @message.present?
|
||||
@conversation_uuid = @conversation.uuid if @conversation.present?
|
||||
end
|
||||
|
||||
# find conversation uuid from below pattern
|
||||
# <conversation/#{@conversation.uuid}/messages/#{@messages&.last&.id}@#{@account.inbound_email_domain}>
|
||||
def find_conversation_with_in_reply_to
|
||||
match_result = nil
|
||||
in_reply_to_addresses = mail.in_reply_to
|
||||
in_reply_to_addresses = [in_reply_to_addresses] if in_reply_to_addresses.is_a?(String)
|
||||
in_reply_to_addresses.each do |in_reply_to|
|
||||
match_result = in_reply_to.match(::ApplicationMailbox::CONVERSATION_MESSAGE_ID_PATTERN)
|
||||
break if match_result
|
||||
end
|
||||
find_by_in_reply_to_addresses(match_result, in_reply_to_addresses)
|
||||
end
|
||||
|
||||
def find_by_in_reply_to_addresses(match_result, in_reply_to_addresses)
|
||||
find_conversation_by_uuid(match_result) if match_result
|
||||
find_conversation_by_message_id(in_reply_to_addresses) if @conversation.blank?
|
||||
end
|
||||
|
||||
def validate_resource(resource)
|
||||
Rails.logger.error "[App::Mailboxes::ReplyMailbox] Email conversation with uuid: #{conversation_uuid} not found" if resource.nil?
|
||||
|
||||
resource
|
||||
@conversation.save!
|
||||
Rails.logger.info "Created new conversation #{@conversation.id} for email #{mail.message_id}"
|
||||
end
|
||||
|
||||
def decorate_mail
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
class SupportMailbox < ApplicationMailbox
|
||||
include IncomingEmailValidityHelper
|
||||
attr_accessor :channel, :account, :inbox, :conversation, :processed_mail
|
||||
|
||||
before_processing :find_channel,
|
||||
:load_account,
|
||||
:load_inbox,
|
||||
:decorate_mail
|
||||
|
||||
def process
|
||||
Rails.logger.info "Processing email #{mail.message_id} from #{original_sender_email} to #{mail.to} with subject #{mail.subject}"
|
||||
|
||||
# Skip processing email if it belongs to any of the edge cases
|
||||
return unless incoming_email_from_valid_email?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
find_or_create_contact
|
||||
find_or_create_conversation
|
||||
create_message
|
||||
add_attachments_to_message
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_channel
|
||||
find_channel_with_to_mail if @channel.blank?
|
||||
|
||||
raise 'Email channel/inbox not found' if @channel.nil?
|
||||
|
||||
@channel
|
||||
end
|
||||
|
||||
def find_channel_with_to_mail
|
||||
@channel = EmailChannelFinder.new(mail).perform
|
||||
end
|
||||
|
||||
def load_account
|
||||
@account = @channel.account
|
||||
end
|
||||
|
||||
def load_inbox
|
||||
@inbox = @channel.inbox
|
||||
end
|
||||
|
||||
def decorate_mail
|
||||
@processed_mail = MailPresenter.new(mail, @account)
|
||||
end
|
||||
|
||||
def find_conversation_by_in_reply_to
|
||||
return if in_reply_to.blank?
|
||||
|
||||
@account.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first
|
||||
end
|
||||
|
||||
def in_reply_to
|
||||
mail['In-Reply-To'].try(:value)
|
||||
end
|
||||
|
||||
def original_sender_email
|
||||
@processed_mail.original_sender&.downcase
|
||||
end
|
||||
|
||||
def find_or_create_conversation
|
||||
@conversation = find_conversation_by_in_reply_to || ::Conversation.create!({
|
||||
account_id: @account.id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: @contact.id,
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: {
|
||||
in_reply_to: in_reply_to,
|
||||
source: 'email',
|
||||
auto_reply: @processed_mail.auto_reply?,
|
||||
mail_subject: @processed_mail.subject,
|
||||
initiated_at: {
|
||||
timestamp: Time.now.utc
|
||||
}
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
def find_or_create_contact
|
||||
@contact = @inbox.contacts.from_email(original_sender_email)
|
||||
if @contact.present?
|
||||
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
|
||||
else
|
||||
create_contact
|
||||
end
|
||||
end
|
||||
|
||||
def identify_contact_name
|
||||
processed_mail.sender_name || processed_mail.from.first.split('@').first
|
||||
end
|
||||
end
|
||||
@@ -35,4 +35,21 @@ class ReportingEvent < ApplicationRecord
|
||||
belongs_to :user, optional: true
|
||||
belongs_to :inbox, optional: true
|
||||
belongs_to :conversation, optional: true
|
||||
|
||||
# Scopes for filtering
|
||||
scope :filter_by_date_range, lambda { |range|
|
||||
where(created_at: range) if range.present?
|
||||
}
|
||||
|
||||
scope :filter_by_inbox_id, lambda { |inbox_id|
|
||||
where(inbox_id: inbox_id) if inbox_id.present?
|
||||
}
|
||||
|
||||
scope :filter_by_user_id, lambda { |user_id|
|
||||
where(user_id: user_id) if user_id.present?
|
||||
}
|
||||
|
||||
scope :filter_by_name, lambda { |name|
|
||||
where(name: name) if name.present?
|
||||
}
|
||||
end
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
class Mailbox::ConversationFinder
|
||||
DEFAULT_STRATEGIES = [
|
||||
Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy,
|
||||
Mailbox::ConversationFinderStrategies::InReplyToStrategy,
|
||||
Mailbox::ConversationFinderStrategies::ReferencesStrategy,
|
||||
Mailbox::ConversationFinderStrategies::NewConversationStrategy
|
||||
].freeze
|
||||
|
||||
def initialize(mail, strategies: DEFAULT_STRATEGIES)
|
||||
@mail = mail
|
||||
@strategies = strategies
|
||||
end
|
||||
|
||||
def find
|
||||
@strategies.each do |strategy_class|
|
||||
conversation = strategy_class.new(@mail).find
|
||||
|
||||
next unless conversation
|
||||
|
||||
strategy_name = strategy_class.name.demodulize.underscore
|
||||
Rails.logger.info "Conversation found via #{strategy_name} strategy"
|
||||
return conversation
|
||||
end
|
||||
|
||||
# Should not reach here if NewConversationStrategy is in the chain
|
||||
Rails.logger.error 'No conversation found via any strategy (NewConversationStrategy missing?)'
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class Mailbox::ConversationFinderStrategies::BaseStrategy
|
||||
attr_reader :mail
|
||||
|
||||
def initialize(mail)
|
||||
@mail = mail
|
||||
end
|
||||
|
||||
# Returns Conversation or nil
|
||||
# Subclasses must implement this method
|
||||
def find
|
||||
raise NotImplementedError, "#{self.class} must implement #find"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
class Mailbox::ConversationFinderStrategies::InReplyToStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
|
||||
# Patterns from ApplicationMailbox
|
||||
MESSAGE_PATTERN = %r{conversation/([a-zA-Z0-9-]+)/messages/(\d+)@}
|
||||
|
||||
# FALLBACK_PATTERN is used when building In-Reply-To headers in ConversationReplyMailer
|
||||
# when there's no actual message to reply to (see app/mailers/conversation_reply_mailer.rb#in_reply_to_email).
|
||||
# This happens when:
|
||||
# - A conversation is started by an agent (no incoming message yet)
|
||||
# - The conversation originated from a non-email channel (widget, WhatsApp, etc.) but is now using email
|
||||
# - The incoming message doesn't have email metadata with a message_id
|
||||
# In these cases, we use a conversation-level identifier instead of a message-level one.
|
||||
FALLBACK_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
|
||||
|
||||
def find
|
||||
return nil if mail.in_reply_to.blank?
|
||||
|
||||
in_reply_to_addresses = Array.wrap(mail.in_reply_to)
|
||||
|
||||
in_reply_to_addresses.each do |in_reply_to|
|
||||
# Try extracting UUID from patterns
|
||||
uuid = extract_uuid_from_patterns(in_reply_to)
|
||||
if uuid
|
||||
conversation = Conversation.find_by(uuid: uuid)
|
||||
return conversation if conversation
|
||||
end
|
||||
|
||||
# Try finding by message source_id
|
||||
message = Message.find_by(source_id: in_reply_to)
|
||||
return message.conversation if message&.conversation
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_uuid_from_patterns(message_id)
|
||||
# Try message-specific pattern first
|
||||
match = MESSAGE_PATTERN.match(message_id)
|
||||
return match[1] if match
|
||||
|
||||
# Try conversation fallback pattern
|
||||
match = FALLBACK_PATTERN.match(message_id)
|
||||
return match[2] if match
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
|
||||
include MailboxHelper
|
||||
include IncomingEmailValidityHelper
|
||||
|
||||
attr_accessor :processed_mail, :account, :inbox, :contact, :contact_inbox, :conversation, :channel
|
||||
|
||||
def initialize(mail)
|
||||
super(mail)
|
||||
@channel = EmailChannelFinder.new(mail).perform
|
||||
return unless @channel
|
||||
|
||||
@account = @channel.account
|
||||
@inbox = @channel.inbox
|
||||
@processed_mail = MailPresenter.new(mail, @account)
|
||||
end
|
||||
|
||||
# This strategy prepares a new conversation but doesn't persist it yet.
|
||||
# Why we don't use create! here:
|
||||
# - Avoids orphan conversations if message/attachment creation fails later
|
||||
# - Prevents duplicate conversations on job retry (no idempotency issue)
|
||||
# - Follows the pattern from old SupportMailbox where everything was in one transaction
|
||||
# The actual persistence happens in ReplyMailbox within a transaction that includes message creation.
|
||||
def find
|
||||
return nil unless @channel # No valid channel found
|
||||
return nil unless incoming_email_from_valid_email? # Skip edge cases
|
||||
|
||||
# Check if conversation already exists by in_reply_to
|
||||
existing_conversation = find_conversation_by_in_reply_to
|
||||
return existing_conversation if existing_conversation
|
||||
|
||||
# Prepare contact (persisted) and build conversation (not persisted)
|
||||
find_or_create_contact
|
||||
build_conversation
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_or_create_contact
|
||||
@contact = @inbox.contacts.from_email(original_sender_email)
|
||||
if @contact.present?
|
||||
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
|
||||
else
|
||||
create_contact
|
||||
end
|
||||
end
|
||||
|
||||
def original_sender_email
|
||||
@processed_mail.original_sender&.downcase
|
||||
end
|
||||
|
||||
def identify_contact_name
|
||||
@processed_mail.sender_name || @processed_mail.from.first.split('@').first
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
# Build but don't persist - ReplyMailbox will save in transaction with message
|
||||
@conversation = ::Conversation.new(
|
||||
account_id: @account.id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: @contact.id,
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: {
|
||||
in_reply_to: in_reply_to,
|
||||
source: 'email',
|
||||
auto_reply: @processed_mail.auto_reply?,
|
||||
mail_subject: @processed_mail.subject,
|
||||
initiated_at: {
|
||||
timestamp: Time.now.utc
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def in_reply_to
|
||||
mail['In-Reply-To'].try(:value)
|
||||
end
|
||||
|
||||
def find_conversation_by_in_reply_to
|
||||
return if in_reply_to.blank?
|
||||
|
||||
@account.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
class Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
|
||||
# Pattern from ApplicationMailbox::REPLY_EMAIL_UUID_PATTERN
|
||||
UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
|
||||
|
||||
def find
|
||||
uuid = extract_uuid_from_receivers
|
||||
return nil unless uuid
|
||||
|
||||
Conversation.find_by(uuid: uuid)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_uuid_from_receivers
|
||||
mail_presenter = MailPresenter.new(mail)
|
||||
return nil if mail_presenter.mail_receiver.blank?
|
||||
|
||||
mail_presenter.mail_receiver.each do |email|
|
||||
username = email.split('@').first
|
||||
match = username.match(UUID_PATTERN)
|
||||
return match[1] if match
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
class Mailbox::ConversationFinderStrategies::ReferencesStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
|
||||
# Patterns from ApplicationMailbox
|
||||
MESSAGE_PATTERN = %r{conversation/([a-zA-Z0-9-]+)/messages/(\d+)@}
|
||||
|
||||
# FALLBACK_PATTERN is used when building References headers in ConversationReplyMailer
|
||||
# when there's no actual message to reply to (see app/mailers/conversation_reply_mailer.rb#in_reply_to_email).
|
||||
# This happens when:
|
||||
# - A conversation is started by an agent (no incoming message yet)
|
||||
# - The conversation originated from a non-email channel (widget, WhatsApp, etc.) but is now using email
|
||||
# - The incoming message doesn't have email metadata with a message_id
|
||||
# In these cases, we use a conversation-level identifier instead of a message-level one.
|
||||
FALLBACK_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
|
||||
|
||||
def initialize(mail)
|
||||
super(mail)
|
||||
# Get channel once upfront to use for scoped queries
|
||||
@channel = EmailChannelFinder.new(mail).perform
|
||||
end
|
||||
|
||||
def find
|
||||
return nil if mail.references.blank?
|
||||
return nil unless @channel # No valid channel found
|
||||
|
||||
references = Array.wrap(mail.references)
|
||||
|
||||
references.each do |reference|
|
||||
conversation = find_conversation_from_reference(reference)
|
||||
return conversation if conversation
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_conversation_from_reference(reference)
|
||||
# Try extracting UUID from patterns
|
||||
uuid = extract_uuid_from_patterns(reference)
|
||||
if uuid
|
||||
# Query scoped to inbox - prevents cross-account/cross-inbox matches at database level
|
||||
conversation = Conversation.find_by(uuid: uuid, inbox_id: @channel.inbox.id)
|
||||
return conversation if conversation
|
||||
end
|
||||
|
||||
# We scope to the inbox, that way we filter out messages and conversations that don't belong to the channel
|
||||
message = Message.find_by(source_id: reference, inbox_id: @channel.inbox.id)
|
||||
message&.conversation
|
||||
end
|
||||
|
||||
def extract_uuid_from_patterns(message_id)
|
||||
match = MESSAGE_PATTERN.match(message_id)
|
||||
return match[1] if match
|
||||
|
||||
match = FALLBACK_PATTERN.match(message_id)
|
||||
return match[2] if match
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -141,6 +141,7 @@ Rails.application.routes.draw do
|
||||
post :custom_attributes
|
||||
get :attachments
|
||||
get :inbox_assistant
|
||||
get :reporting_events if ChatwootApp.enterprise?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -186,6 +187,7 @@ Rails.application.routes.draw do
|
||||
get :download
|
||||
end
|
||||
end
|
||||
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
|
||||
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
|
||||
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
|
||||
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
class Api::V1::Accounts::ReportingEventsController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
include DateRangeHelper
|
||||
|
||||
RESULTS_PER_PAGE = 25
|
||||
|
||||
before_action :check_admin_authorization?
|
||||
before_action :set_reporting_events, only: [:index]
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
def index
|
||||
@reporting_events = @reporting_events.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
@total_count = @reporting_events.total_count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_reporting_events
|
||||
@reporting_events = Current.account.reporting_events
|
||||
.includes(:conversation, :user, :inbox)
|
||||
.filter_by_date_range(range)
|
||||
.filter_by_inbox_id(params[:inbox_id])
|
||||
.filter_by_user_id(params[:user_id])
|
||||
.filter_by_name(params[:name])
|
||||
.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def set_current_page
|
||||
@current_page = (params[:page] || 1).to_i
|
||||
end
|
||||
end
|
||||
@@ -11,6 +11,10 @@ module Enterprise::Api::V1::Accounts::ConversationsController
|
||||
end
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events = @conversation.reporting_events.order(created_at: :asc)
|
||||
end
|
||||
|
||||
def permitted_update_params
|
||||
super.merge(params.permit(:sla_policy_id))
|
||||
end
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
json.array! @reporting_events do |reporting_event|
|
||||
json.partial! 'api/v1/models/reporting_event', formats: [:json], reporting_event: reporting_event
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
json.payload do
|
||||
json.array! @reporting_events do |reporting_event|
|
||||
json.partial! 'api/v1/models/reporting_event', formats: [:json], reporting_event: reporting_event
|
||||
end
|
||||
end
|
||||
|
||||
json.meta do
|
||||
json.count @total_count
|
||||
json.current_page @current_page
|
||||
json.total_pages @reporting_events.total_pages
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
json.id reporting_event.id
|
||||
json.name reporting_event.name
|
||||
json.value reporting_event.value
|
||||
json.value_in_business_hours reporting_event.value_in_business_hours
|
||||
json.event_start_time reporting_event.event_start_time
|
||||
json.event_end_time reporting_event.event_end_time
|
||||
json.account_id reporting_event.account_id
|
||||
json.inbox_id reporting_event.inbox_id
|
||||
json.user_id reporting_event.user_id
|
||||
json.conversation_id reporting_event.conversation_id
|
||||
json.created_at reporting_event.created_at
|
||||
json.updated_at reporting_event.updated_at
|
||||
@@ -28,7 +28,7 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
|
||||
Question: #{response.question}
|
||||
Answer: #{response.answer}
|
||||
"
|
||||
if response.documentable.present? && response.documentable.try(:external_link)
|
||||
if should_show_source?(response)
|
||||
formatted_response += "
|
||||
Source: #{response.documentable.external_link}
|
||||
"
|
||||
@@ -36,4 +36,13 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
|
||||
|
||||
formatted_response
|
||||
end
|
||||
|
||||
def should_show_source?(response)
|
||||
return false if response.documentable.blank?
|
||||
return false unless response.documentable.try(:external_link)
|
||||
|
||||
# Don't show source if it's a PDF placeholder
|
||||
external_link = response.documentable.external_link
|
||||
!external_link.start_with?('PDF:')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -223,6 +223,69 @@ describe Messages::MessageBuilder do
|
||||
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular **markdown** content'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when liquid templates are present in email content' do
|
||||
let(:contact) { create(:contact, name: 'John', email: 'john@example.com') }
|
||||
let(:conversation) { create(:conversation, inbox: channel_email.inbox, account: account, contact: contact) }
|
||||
|
||||
it 'processes liquid variables in email content' do
|
||||
params = ActionController::Parameters.new({
|
||||
content: 'Hello {{contact.name}}, your email is {{contact.email}}'
|
||||
})
|
||||
|
||||
message = described_class.new(user, conversation, params).perform
|
||||
|
||||
expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('Hello John')
|
||||
expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('john@example.com')
|
||||
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello John, your email is john@example.com'
|
||||
end
|
||||
|
||||
it 'does not process liquid in code blocks' do
|
||||
params = ActionController::Parameters.new({
|
||||
content: 'Hello {{contact.name}}, use this code: `{{contact.email}}`'
|
||||
})
|
||||
|
||||
message = described_class.new(user, conversation, params).perform
|
||||
|
||||
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello John, use this code: `{{contact.email}}`'
|
||||
end
|
||||
|
||||
it 'handles broken liquid syntax gracefully' do
|
||||
params = ActionController::Parameters.new({
|
||||
content: 'Hello {{contact.name} {{invalid}}'
|
||||
})
|
||||
|
||||
message = described_class.new(user, conversation, params).perform
|
||||
|
||||
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello {{contact.name} {{invalid}}'
|
||||
end
|
||||
|
||||
it 'does not process liquid for incoming messages' do
|
||||
params = ActionController::Parameters.new({
|
||||
content: 'Hello {{contact.name}}',
|
||||
message_type: 'incoming'
|
||||
})
|
||||
|
||||
api_channel = create(:channel_api, account: account)
|
||||
api_conversation = create(:conversation, inbox: api_channel.inbox, account: account, contact: contact)
|
||||
|
||||
message = described_class.new(user, api_conversation, params).perform
|
||||
|
||||
expect(message.content).to eq 'Hello {{contact.name}}'
|
||||
end
|
||||
|
||||
it 'does not process liquid for private messages' do
|
||||
params = ActionController::Parameters.new({
|
||||
content: 'Hello {{contact.name}}',
|
||||
private: true
|
||||
})
|
||||
|
||||
message = described_class.new(user, conversation, params).perform
|
||||
|
||||
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
|
||||
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,7 +36,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['payload']['title']).to eql('MyTitle')
|
||||
expect(json_response['payload']['status']).to eql('draft')
|
||||
expect(json_response['payload']['status']).to eql('published')
|
||||
expect(json_response['payload']['position']).to be(3)
|
||||
end
|
||||
|
||||
@@ -59,10 +59,30 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['payload']['title']).to eql('MyTitle')
|
||||
expect(json_response['payload']['status']).to eql('draft')
|
||||
expect(json_response['payload']['status']).to eql('published')
|
||||
expect(json_response['payload']['position']).to be(3)
|
||||
end
|
||||
|
||||
it 'creates article as draft when status is not provided' do
|
||||
article_params = {
|
||||
article: {
|
||||
category_id: category.id,
|
||||
description: 'test description',
|
||||
title: 'DraftTitle',
|
||||
slug: 'draft-title',
|
||||
content: 'This is my draft content.',
|
||||
author_id: agent.id
|
||||
}
|
||||
}
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
|
||||
params: article_params,
|
||||
headers: admin.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['payload']['title']).to eql('DraftTitle')
|
||||
expect(json_response['payload']['status']).to eql('draft')
|
||||
end
|
||||
|
||||
it 'associate to the root article' do
|
||||
root_article = create(:article, category: category, slug: 'root-article', portal: portal, account_id: account.id, author_id: agent.id,
|
||||
associated_article_id: nil)
|
||||
|
||||
@@ -102,4 +102,146 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/conversations/:id/reporting_events' do
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:inbox) { conversation.inbox }
|
||||
let(:agent) { administrator }
|
||||
|
||||
before do
|
||||
# Create reporting events for this conversation
|
||||
@event1 = create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'first_response',
|
||||
value: 120,
|
||||
created_at: 3.hours.ago)
|
||||
|
||||
@event2 = create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'reply_time',
|
||||
value: 45,
|
||||
created_at: 2.hours.ago)
|
||||
|
||||
@event3 = create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'resolution',
|
||||
value: 300,
|
||||
created_at: 1.hour.ago)
|
||||
|
||||
# Create an event for a different conversation (should not be included)
|
||||
other_conversation = create(:conversation, account: account)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: other_conversation,
|
||||
inbox: other_conversation.inbox,
|
||||
user: agent,
|
||||
name: 'other_conversation_event',
|
||||
value: 60)
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user with conversation access' do
|
||||
it 'returns all reporting events for the conversation' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
# Should return array directly (no pagination)
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response.size).to eq(3)
|
||||
|
||||
# Check they are sorted by created_at asc (oldest first)
|
||||
expect(json_response.first['name']).to eq('first_response')
|
||||
expect(json_response.last['name']).to eq('resolution')
|
||||
|
||||
# Verify it doesn't include events from other conversations
|
||||
event_names = json_response.map { |e| e['name'] }
|
||||
expect(event_names).not_to include('other_conversation_event')
|
||||
end
|
||||
|
||||
it 'returns empty array when conversation has no reporting events' do
|
||||
conversation_without_events = create(:conversation, account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation_without_events.display_id}/reporting_events",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent has limited access' do
|
||||
let(:limited_agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized for unassigned conversation without permission' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
headers: limited_agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns reporting events when agent is assigned to the conversation' do
|
||||
conversation.update!(assignee: limited_agent)
|
||||
# Also create inbox member for the agent
|
||||
create(:inbox_member, user: limited_agent, inbox: conversation.inbox)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
headers: limited_agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response.size).to eq(3)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent has team access' do
|
||||
let(:team_agent) { create(:user, account: account, role: :agent) }
|
||||
let(:team) { create(:team, account: account) }
|
||||
|
||||
before do
|
||||
create(:team_member, team: team, user: team_agent)
|
||||
conversation.update!(team: team)
|
||||
end
|
||||
|
||||
it 'allows accessing conversation reporting events via team membership' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
headers: team_agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response.size).to eq(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Reporting Events API', type: :request do
|
||||
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) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/reporting_events' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated normal agent user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated admin user' do
|
||||
before do
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'first_response',
|
||||
value: 120,
|
||||
created_at: 3.days.ago)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'resolution',
|
||||
value: 300,
|
||||
created_at: 2.days.ago)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'reply_time',
|
||||
value: 45,
|
||||
created_at: 1.day.ago)
|
||||
end
|
||||
|
||||
it 'fetches reporting events with pagination' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
# Check structure and pagination
|
||||
expect(json_response).to have_key('payload')
|
||||
expect(json_response).to have_key('meta')
|
||||
expect(json_response['meta']['count']).to eq(3)
|
||||
|
||||
# Check events are sorted by created_at desc (newest first)
|
||||
events = json_response['payload']
|
||||
expect(events.size).to eq(3)
|
||||
expect(events.first['name']).to eq('reply_time')
|
||||
expect(events.last['name']).to eq('first_response')
|
||||
end
|
||||
|
||||
it 'filters reporting events by date range using since and until' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { since: 2.5.days.ago.to_time.to_i.to_s, until: 1.5.days.ago.to_time.to_i.to_s },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(1)
|
||||
expect(json_response['payload'].first['name']).to eq('resolution')
|
||||
end
|
||||
|
||||
it 'filters reporting events by inbox_id' do
|
||||
other_inbox = create(:inbox, account: account)
|
||||
other_conversation = create(:conversation, account: account, inbox: other_inbox)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: other_conversation,
|
||||
inbox: other_inbox,
|
||||
user: agent,
|
||||
name: 'other_inbox_event')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { inbox_id: inbox.id },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(3)
|
||||
expect(json_response['payload'].map { |e| e['name'] }).not_to include('other_inbox_event')
|
||||
end
|
||||
|
||||
it 'filters reporting events by user_id (agent)' do
|
||||
other_agent = create(:user, account: account, role: :agent)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: other_agent,
|
||||
name: 'other_agent_event')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { user_id: agent.id },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(3)
|
||||
expect(json_response['payload'].map { |e| e['name'] }).not_to include('other_agent_event')
|
||||
end
|
||||
|
||||
it 'filters reporting events by name' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { name: 'first_response' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(1)
|
||||
expect(json_response['payload'].first['name']).to eq('first_response')
|
||||
end
|
||||
|
||||
it 'supports combining multiple filters' do
|
||||
# Create more test data
|
||||
other_conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: other_conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'first_response',
|
||||
value: 90,
|
||||
created_at: 2.days.ago)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: {
|
||||
inbox_id: inbox.id,
|
||||
user_id: agent.id,
|
||||
name: 'first_response',
|
||||
since: 4.days.ago.to_time.to_i.to_s,
|
||||
until: Time.zone.now.to_time.to_i.to_s
|
||||
},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(2)
|
||||
expect(json_response['payload'].map { |e| e['name'] }).to all(eq('first_response'))
|
||||
end
|
||||
|
||||
context 'with pagination' do
|
||||
before do
|
||||
# Create more events to test pagination
|
||||
30.times do |i|
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: "event_#{i}",
|
||||
created_at: i.hours.ago)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns 25 events per page by default' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['payload'].size).to eq(25)
|
||||
expect(json_response['meta']['count']).to eq(33) # 30 + 3 original events
|
||||
expect(json_response['meta']['current_page']).to eq(1)
|
||||
end
|
||||
|
||||
it 'supports page navigation' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { page: 2 },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['payload'].size).to eq(8) # Remaining events
|
||||
expect(json_response['meta']['current_page']).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,11 +1,13 @@
|
||||
FactoryBot.define do
|
||||
factory :reporting_event do
|
||||
name { 'MyString' }
|
||||
name { 'first_response' }
|
||||
value { 1.5 }
|
||||
value_in_business_hours { 1 }
|
||||
account_id { 1 }
|
||||
inbox_id { 1 }
|
||||
user_id { 1 }
|
||||
conversation_id { 1 }
|
||||
account
|
||||
inbox { association :inbox, account: account }
|
||||
user { association :user, account: account }
|
||||
conversation { association :conversation, account: account, inbox: inbox }
|
||||
event_start_time { 2.hours.ago }
|
||||
event_end_time { 1.hour.ago }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -41,28 +41,31 @@ RSpec.describe ApplicationMailbox do
|
||||
describe 'Support' do
|
||||
let!(:channel_email) { create(:channel_email) }
|
||||
|
||||
it 'routes support emails to Support Mailbox when mail is to channel email' do
|
||||
it 'routes support emails to Reply Mailbox when mail is to channel email' do
|
||||
# this email is hardcoded in the support.eml, that's why we are updating this
|
||||
# With NewConversationStrategy, all channel emails route to ReplyMailbox
|
||||
channel_email.update(email: 'care@example.com')
|
||||
dbl = double
|
||||
expect(SupportMailbox).to receive(:new).and_return(dbl)
|
||||
expect(ReplyMailbox).to receive(:new).and_return(dbl)
|
||||
expect(dbl).to receive(:perform_processing).and_return(true)
|
||||
described_class.route support_mail
|
||||
end
|
||||
|
||||
it 'routes support emails to Support Mailbox when mail is to channel forward to email' do
|
||||
it 'routes support emails to Reply Mailbox when mail is to channel forward to email' do
|
||||
# this email is hardcoded in the support.eml, that's why we are updating this
|
||||
# With NewConversationStrategy, all channel emails route to ReplyMailbox
|
||||
channel_email.update(forward_to_email: 'care@example.com')
|
||||
dbl = double
|
||||
expect(SupportMailbox).to receive(:new).and_return(dbl)
|
||||
expect(ReplyMailbox).to receive(:new).and_return(dbl)
|
||||
expect(dbl).to receive(:perform_processing).and_return(true)
|
||||
described_class.route support_mail
|
||||
end
|
||||
|
||||
it 'routes support emails to Support Mailbox with cc email' do
|
||||
it 'routes support emails to Reply Mailbox with cc email' do
|
||||
# With NewConversationStrategy, all channel emails route to ReplyMailbox
|
||||
channel_email.update(email: 'test@example.com')
|
||||
dbl = double
|
||||
expect(SupportMailbox).to receive(:new).and_return(dbl)
|
||||
expect(ReplyMailbox).to receive(:new).and_return(dbl)
|
||||
expect(dbl).to receive(:perform_processing).and_return(true)
|
||||
described_class.route reply_cc_mail
|
||||
end
|
||||
|
||||
@@ -264,5 +264,54 @@ RSpec.describe Imap::ImapMailbox do
|
||||
expect(conversation.additional_attributes['in_reply_to']).to eq(multiple_in_reply_to_mail.in_reply_to.first)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a reply to a conversation started by an agent' do
|
||||
let(:agent_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
|
||||
let(:reply_mail_with_fallback_reference) do
|
||||
# Simulate an email reply with a reference that matches FALLBACK_PATTERN
|
||||
reference_id = "account/#{account.id}/conversation/#{agent_conversation.uuid}@chatwoot.com"
|
||||
create_inbound_email_from_mail(
|
||||
from: 'email@gmail.com',
|
||||
to: 'imap@gmail.com',
|
||||
subject: 'Re: Agent started conversation',
|
||||
references: [reference_id]
|
||||
)
|
||||
end
|
||||
|
||||
it 'appends email to the existing conversation using FALLBACK_PATTERN' do
|
||||
expect(agent_conversation.messages.size).to eq(0)
|
||||
|
||||
class_instance.process(reply_mail_with_fallback_reference.mail, channel)
|
||||
|
||||
agent_conversation.reload
|
||||
expect(agent_conversation.messages.size).to eq(1)
|
||||
expect(agent_conversation.messages.last.content_attributes['email']['from']).to eq(reply_mail_with_fallback_reference.mail.from)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references contain both message and fallback patterns' do
|
||||
let(:agent_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
|
||||
let(:reply_mail_with_multiple_references) do
|
||||
# Multiple references including both patterns
|
||||
fallback_reference = "account/#{account.id}/conversation/#{agent_conversation.uuid}@chatwoot.com"
|
||||
other_reference = 'some-other-message-id@example.com'
|
||||
create_inbound_email_from_mail(
|
||||
from: 'email@gmail.com',
|
||||
to: 'imap@gmail.com',
|
||||
subject: 'Re: Multiple references',
|
||||
references: [other_reference, fallback_reference]
|
||||
)
|
||||
end
|
||||
|
||||
it 'finds conversation using fallback pattern when message lookup fails' do
|
||||
expect(agent_conversation.messages.size).to eq(0)
|
||||
|
||||
class_instance.process(reply_mail_with_multiple_references.mail, channel)
|
||||
|
||||
agent_conversation.reload
|
||||
expect(agent_conversation.messages.size).to eq(1)
|
||||
expect(agent_conversation.messages.last.content_attributes['email']['from']).to eq(reply_mail_with_multiple_references.mail.from)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -248,5 +248,448 @@ RSpec.describe ReplyMailbox do
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with references header' do
|
||||
let(:reply_mail_with_references) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation_1) do
|
||||
create(
|
||||
:conversation,
|
||||
assignee: agent,
|
||||
inbox: email_channel.inbox,
|
||||
account: account,
|
||||
additional_attributes: { mail_subject: "Discussion: Let's debate these attachments" }
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
conversation_1.update!(uuid: '6bdc3f4d-0bec-4515-a284-5d916fdde489')
|
||||
end
|
||||
|
||||
context 'with message-specific pattern in references' do
|
||||
before do
|
||||
reply_mail_with_references.mail['References'] = '<conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123@test.com>'
|
||||
end
|
||||
|
||||
it 'finds conversation from references header with message pattern' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with conversation fallback pattern in references' do
|
||||
before do
|
||||
reply_mail_with_references.mail['References'] = "<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>"
|
||||
end
|
||||
|
||||
it 'finds conversation from references header with fallback pattern' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple references including conversation pattern' do
|
||||
before do
|
||||
reply_mail_with_references.mail['References'] = [
|
||||
'<some-random-message-id@gmail.com>',
|
||||
"<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>",
|
||||
'<another-random-message-id@outlook.com>'
|
||||
].join("\r\n ")
|
||||
end
|
||||
|
||||
it 'finds conversation from any reference in the chain' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with message source_id in references' do
|
||||
before do
|
||||
conversation_1.messages.create!(
|
||||
source_id: 'original-message-id@test.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Original message'
|
||||
)
|
||||
reply_mail_with_references.mail['References'] = '<original-message-id@test.com>'
|
||||
end
|
||||
|
||||
it 'finds conversation from message source_id in references' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with conversation from different channel in references' do
|
||||
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
|
||||
let(:other_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
assignee: agent,
|
||||
inbox: other_email_channel.inbox,
|
||||
account: account
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
reply_mail_with_references.mail['References'] = '<conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@test.com>'
|
||||
end
|
||||
|
||||
it 'does not use conversation from different channel' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(other_conversation.messages.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when a chatwoot notification email is received' do
|
||||
let(:account) { create(:account) }
|
||||
let!(:channel_email) { create(:channel_email, email: 'sojan@chatwoot.com', account: account) }
|
||||
let(:notification_mail) { create_inbound_email_from_fixture('notification.eml') }
|
||||
let(:described_subject) { described_class.receive notification_mail }
|
||||
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
|
||||
|
||||
it 'shouldnt create a conversation in the channel' do
|
||||
described_subject
|
||||
expect(conversation.present?).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when bounced email with out a sender is recieved' do
|
||||
let(:account) { create(:account) }
|
||||
let(:bounced_email) { create_inbound_email_from_fixture('bounced_with_no_from.eml') }
|
||||
let(:described_subject) { described_class.receive bounced_email }
|
||||
|
||||
it 'shouldnt throw an error' do
|
||||
create(:channel_email, email: 'support@example.com', account: account)
|
||||
expect { described_subject }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when an account is suspended' do
|
||||
let(:account) { create(:account, status: :suspended) }
|
||||
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
|
||||
let!(:channel_email) { create(:channel_email, account: account) }
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail }
|
||||
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
|
||||
|
||||
before do
|
||||
# this email is hardcoded in the support.eml, that's why we are updating this
|
||||
channel_email.email = 'care@example.com'
|
||||
channel_email.save!
|
||||
end
|
||||
|
||||
it 'shouldnt create a conversation in the channel' do
|
||||
described_subject
|
||||
expect(conversation.present?).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'add mail as a new ticket in the email inbox' do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
|
||||
let!(:channel_email) { create(:channel_email, account: account) }
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
|
||||
let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail }
|
||||
let(:serialized_attributes) do
|
||||
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
|
||||
text_content to auto_reply]
|
||||
end
|
||||
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
|
||||
|
||||
before do
|
||||
# this email is hardcoded in the support.eml, that's why we are updating this
|
||||
channel_email.email = 'care@example.com'
|
||||
channel_email.save!
|
||||
end
|
||||
|
||||
describe 'covers email address format' do
|
||||
before do
|
||||
described_class.receive support_in_reply_to_mail
|
||||
end
|
||||
|
||||
it 'creates contact with proper email address' do
|
||||
expect(support_in_reply_to_mail.mail['reply_to'].try(:value)).to eq('Sony Mathew <sony@chatwoot.com>')
|
||||
expect(conversation.contact.email).to eq('sony@chatwoot.com')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'covers basic ticket creation' do
|
||||
before do
|
||||
described_subject
|
||||
end
|
||||
|
||||
it 'create the conversation in the inbox of the email channel' do
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
expect(conversation.additional_attributes['source']).to eq('email')
|
||||
expect(conversation.contact.email).to eq(support_mail.mail.from.first)
|
||||
end
|
||||
|
||||
it 'create a new contact as the sender of the email' do
|
||||
email_sender = Mail::Address.new(support_mail.mail[:from].value).name
|
||||
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
|
||||
expect(conversation.contact.name).to eq(email_sender)
|
||||
end
|
||||
|
||||
it 'add the mail content as new message on the conversation' do
|
||||
expect(conversation.messages.last.content).to eq("Let's talk about these images:")
|
||||
end
|
||||
|
||||
it 'add the attachments' do
|
||||
expect(conversation.messages.last.attachments.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'have proper content_attributes with details of email' do
|
||||
expect(conversation.messages.last.content_attributes[:email].keys).to eq(serialized_attributes)
|
||||
end
|
||||
|
||||
it 'set proper content_type' do
|
||||
expect(conversation.messages.last.content_type).to eq('incoming_email')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'email with references header' do
|
||||
let(:mail_with_references) { create_inbound_email_from_fixture('mail_with_references.eml') }
|
||||
let(:described_subject) { described_class.receive mail_with_references }
|
||||
|
||||
before do
|
||||
# reuse the existing channel_email that's already set to 'care@example.com'
|
||||
described_subject
|
||||
end
|
||||
|
||||
it 'includes references in the message content_attributes' do
|
||||
message = conversation.messages.last
|
||||
email_attributes = message.content_attributes['email']
|
||||
|
||||
expect(email_attributes['references']).to be_present
|
||||
expect(email_attributes['references']).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id'])
|
||||
end
|
||||
|
||||
it 'includes references in serialized email attributes' do
|
||||
message = conversation.messages.last
|
||||
expect(message.content_attributes['email'].keys).to include('references')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Sender without name' do
|
||||
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail_without_sender_name }
|
||||
|
||||
it 'create a new contact with the email' do
|
||||
described_subject
|
||||
email_sender = support_mail_without_sender_name.mail.from.first.split('@').first
|
||||
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
|
||||
expect(conversation.contact.name).to eq(email_sender)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Sender with upcase mail address' do
|
||||
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail_without_sender_name }
|
||||
|
||||
it 'create a new inbox with the email case insensitive' do
|
||||
described_subject
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'handle inbox contacts' do
|
||||
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
|
||||
|
||||
it 'does not create new contact if that contact exists in the inbox' do
|
||||
expect do
|
||||
described_subject
|
||||
end
|
||||
.to(not_change { Contact.count }
|
||||
.and(not_change { ContactInbox.count }))
|
||||
|
||||
expect(conversation.messages.last.sender.id).to eq(contact.id)
|
||||
expect(conversation.contact_inbox).to eq(contact_inbox)
|
||||
end
|
||||
|
||||
context 'with uppercase reply-to' do
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support_uppercase.eml') }
|
||||
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
|
||||
|
||||
it 'does not create new contact if that contact exists in the inbox' do
|
||||
expect do
|
||||
described_subject
|
||||
end
|
||||
.to(not_change { Contact.count }
|
||||
.and(not_change { ContactInbox.count }))
|
||||
|
||||
expect(conversation.messages.last.sender.id).to eq(contact.id)
|
||||
expect(conversation.contact_inbox).to eq(contact_inbox)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'group email sender' do
|
||||
let(:group_sender_support_mail) { create_inbound_email_from_fixture('group_sender_support.eml') }
|
||||
let(:described_subject) { described_class.receive group_sender_support_mail }
|
||||
|
||||
before do
|
||||
# this email is hardcoded eml fixture file that's why we are updating this
|
||||
channel_email.email = 'support@chatwoot.com'
|
||||
channel_email.save!
|
||||
end
|
||||
|
||||
it 'create new contact with original sender' do
|
||||
described_subject
|
||||
email_sender = Mail::Address.new(group_sender_support_mail.mail[:from].value).name
|
||||
|
||||
expect(conversation.contact.email).to eq(group_sender_support_mail.mail['X-Original-Sender'].value)
|
||||
expect(conversation.contact.name).to eq(email_sender)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when mail has in reply to email' do
|
||||
let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
|
||||
let(:described_subject) { described_class.receive reply_mail_without_uuid }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
|
||||
before do
|
||||
email_channel
|
||||
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
|
||||
end
|
||||
|
||||
it 'create channel with reply to mail' do
|
||||
described_subject
|
||||
conversation_1 = Conversation.last
|
||||
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
|
||||
end
|
||||
|
||||
it 'append message to email conversation with same in reply to' do
|
||||
described_subject
|
||||
conversation_1 = Conversation.last
|
||||
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
|
||||
expect(conversation_1.messages.count).to eq(1)
|
||||
|
||||
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
|
||||
reply_mail_without_uuid.mail['Message-Id'] = '0CB459E0-0336-41DA-BC88-E6E28C697SFC@chatwoot.com'
|
||||
|
||||
described_class.receive reply_mail_without_uuid
|
||||
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
|
||||
expect(conversation_1.messages.count).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Sender with reply_to email address' do
|
||||
let(:reply_to_mail) { create_inbound_email_from_fixture('reply_to.eml') }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
|
||||
it 'prefer reply-to over from address' do
|
||||
email_channel
|
||||
described_class.receive reply_to_mail
|
||||
|
||||
conversation_1 = Conversation.last
|
||||
email = conversation_1.messages.last.content_attributes['email']
|
||||
|
||||
expect(reply_to_mail.mail['From'].value).to be_present
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(reply_to_mail.mail['Reply-To'].value).to include(email['from'][0])
|
||||
expect(reply_to_mail.mail['Reply-To'].value).to include(conversation_1.contact.email)
|
||||
expect(reply_to_mail.mail['From'].value).not_to include(conversation_1.contact.email)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when mail part is not present' do
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support_1.eml') }
|
||||
let(:only_text) { create_inbound_email_from_fixture('only_text.eml') }
|
||||
let(:only_html) { create_inbound_email_from_fixture('only_html.eml') }
|
||||
let(:only_attachments) { create_inbound_email_from_fixture('only_attachments.eml') }
|
||||
let(:html_and_attachments) { create_inbound_email_from_fixture('html_and_attachments.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail }
|
||||
|
||||
it 'Considers raw html mail body' do
|
||||
described_subject
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content_attributes['email']['html_content']['reply']).to include(
|
||||
<<~BODY.chomp
|
||||
Hi,
|
||||
|
||||
We are providing you platform from here you can sell paid posts on your website.
|
||||
|
||||
Chatwoot | CS team | [C](https://d33wubrfki0l68.cloudfront.net/973467c532160fd8b940300a43fa85fa2d060307/dc9a0/static/brand-73f58cdefae282ae74cebfa74c1d7003.svg)
|
||||
|
||||
Skype: live:.cid.something
|
||||
|
||||
[]
|
||||
BODY
|
||||
)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('Get Paid to post an article')
|
||||
end
|
||||
|
||||
it 'Considers only text body' do
|
||||
described_class.receive only_text
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to eq('text only mail')
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test text only mail')
|
||||
end
|
||||
|
||||
it 'Considers only html body' do
|
||||
described_class.receive only_html
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to eq(
|
||||
<<~BODY.chomp
|
||||
This is html only mail
|
||||
BODY
|
||||
)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test html only mail')
|
||||
end
|
||||
|
||||
it 'Considers only attachments' do
|
||||
described_class.receive only_attachments
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to be_nil
|
||||
expect(conversation.messages.last.attachments.count).to eq(1)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('only attachments')
|
||||
end
|
||||
|
||||
it 'Considers html and attachments' do
|
||||
described_class.receive html_and_attachments
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to eq('This is html and attachments only mail')
|
||||
expect(conversation.messages.last.attachments.count).to eq(1)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('attachment with html')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when BCC processing is disabled for account' do
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('SKIP_INCOMING_BCC_PROCESSING', '').and_return(account.id.to_s)
|
||||
end
|
||||
|
||||
it 'does not process BCC-only emails' do
|
||||
bcc_mail = create_inbound_email_from_fixture('support.eml')
|
||||
bcc_mail.mail['to'] = nil
|
||||
bcc_mail.mail['bcc'] = 'care@example.com'
|
||||
|
||||
described_class.receive bcc_mail
|
||||
expect(conversation.present?).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SupportMailbox do
|
||||
include ActionMailbox::TestHelper
|
||||
|
||||
describe 'when a chatwoot notification email is received' do
|
||||
let(:account) { create(:account) }
|
||||
let!(:channel_email) { create(:channel_email, email: 'sojan@chatwoot.com', account: account) }
|
||||
let(:notification_mail) { create_inbound_email_from_fixture('notification.eml') }
|
||||
let(:described_subject) { described_class.receive notification_mail }
|
||||
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
|
||||
|
||||
it 'shouldnt create a conversation in the channel' do
|
||||
described_subject
|
||||
expect(conversation.present?).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when bounced email with out a sender is recieved' do
|
||||
let(:account) { create(:account) }
|
||||
let(:bounced_email) { create_inbound_email_from_fixture('bounced_with_no_from.eml') }
|
||||
let(:described_subject) { described_class.receive bounced_email }
|
||||
|
||||
it 'shouldnt throw an error' do
|
||||
create(:channel_email, email: 'support@example.com', account: account)
|
||||
expect { described_subject }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when an account is suspended' do
|
||||
let(:account) { create(:account, status: :suspended) }
|
||||
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
|
||||
let!(:channel_email) { create(:channel_email, account: account) }
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail }
|
||||
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
|
||||
|
||||
before do
|
||||
# this email is hardcoded in the support.eml, that's why we are updating this
|
||||
channel_email.email = 'care@example.com'
|
||||
channel_email.save!
|
||||
end
|
||||
|
||||
it 'shouldnt create a conversation in the channel' do
|
||||
described_subject
|
||||
expect(conversation.present?).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'add mail as a new ticket in the email inbox' do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
|
||||
let!(:channel_email) { create(:channel_email, account: account) }
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
|
||||
let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail }
|
||||
let(:serialized_attributes) do
|
||||
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
|
||||
text_content to auto_reply]
|
||||
end
|
||||
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
|
||||
|
||||
before do
|
||||
# this email is hardcoded in the support.eml, that's why we are updating this
|
||||
channel_email.email = 'care@example.com'
|
||||
channel_email.save!
|
||||
end
|
||||
|
||||
describe 'covers email address format' do
|
||||
before do
|
||||
described_class.receive support_in_reply_to_mail
|
||||
end
|
||||
|
||||
it 'creates contact with proper email address' do
|
||||
expect(support_in_reply_to_mail.mail['reply_to'].try(:value)).to eq('Sony Mathew <sony@chatwoot.com>')
|
||||
expect(conversation.contact.email).to eq('sony@chatwoot.com')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'covers basic ticket creation' do
|
||||
before do
|
||||
described_subject
|
||||
end
|
||||
|
||||
it 'create the conversation in the inbox of the email channel' do
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
expect(conversation.additional_attributes['source']).to eq('email')
|
||||
expect(conversation.contact.email).to eq(support_mail.mail.from.first)
|
||||
end
|
||||
|
||||
it 'create a new contact as the sender of the email' do
|
||||
email_sender = Mail::Address.new(support_mail.mail[:from].value).name
|
||||
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
|
||||
expect(conversation.contact.name).to eq(email_sender)
|
||||
end
|
||||
|
||||
it 'add the mail content as new message on the conversation' do
|
||||
expect(conversation.messages.last.content).to eq("Let's talk about these images:")
|
||||
end
|
||||
|
||||
it 'add the attachments' do
|
||||
expect(conversation.messages.last.attachments.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'have proper content_attributes with details of email' do
|
||||
expect(conversation.messages.last.content_attributes[:email].keys).to eq(serialized_attributes)
|
||||
end
|
||||
|
||||
it 'set proper content_type' do
|
||||
expect(conversation.messages.last.content_type).to eq('incoming_email')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'email with references header' do
|
||||
let(:mail_with_references) { create_inbound_email_from_fixture('mail_with_references.eml') }
|
||||
let(:described_subject) { described_class.receive mail_with_references }
|
||||
|
||||
before do
|
||||
# reuse the existing channel_email that's already set to 'care@example.com'
|
||||
described_subject
|
||||
end
|
||||
|
||||
it 'includes references in the message content_attributes' do
|
||||
message = conversation.messages.last
|
||||
email_attributes = message.content_attributes['email']
|
||||
|
||||
expect(email_attributes['references']).to be_present
|
||||
expect(email_attributes['references']).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id'])
|
||||
end
|
||||
|
||||
it 'includes references in serialized email attributes' do
|
||||
message = conversation.messages.last
|
||||
expect(message.content_attributes['email'].keys).to include('references')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Sender without name' do
|
||||
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail_without_sender_name }
|
||||
|
||||
it 'create a new contact with the email' do
|
||||
described_subject
|
||||
email_sender = support_mail_without_sender_name.mail.from.first.split('@').first
|
||||
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
|
||||
expect(conversation.contact.name).to eq(email_sender)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Sender with upcase mail address' do
|
||||
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail_without_sender_name }
|
||||
|
||||
it 'create a new inbox with the email case insensitive' do
|
||||
described_subject
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'handle inbox contacts' do
|
||||
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
|
||||
|
||||
it 'does not create new contact if that contact exists in the inbox' do
|
||||
expect do
|
||||
described_subject
|
||||
end
|
||||
.to(not_change { Contact.count }
|
||||
.and(not_change { ContactInbox.count }))
|
||||
|
||||
expect(conversation.messages.last.sender.id).to eq(contact.id)
|
||||
expect(conversation.contact_inbox).to eq(contact_inbox)
|
||||
end
|
||||
|
||||
context 'with uppercase reply-to' do
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support_uppercase.eml') }
|
||||
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
|
||||
|
||||
it 'does not create new contact if that contact exists in the inbox' do
|
||||
expect do
|
||||
described_subject
|
||||
end
|
||||
.to(not_change { Contact.count }
|
||||
.and(not_change { ContactInbox.count }))
|
||||
|
||||
expect(conversation.messages.last.sender.id).to eq(contact.id)
|
||||
expect(conversation.contact_inbox).to eq(contact_inbox)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'group email sender' do
|
||||
let(:group_sender_support_mail) { create_inbound_email_from_fixture('group_sender_support.eml') }
|
||||
let(:described_subject) { described_class.receive group_sender_support_mail }
|
||||
|
||||
before do
|
||||
# this email is hardcoded eml fixture file that's why we are updating this
|
||||
channel_email.email = 'support@chatwoot.com'
|
||||
channel_email.save!
|
||||
end
|
||||
|
||||
it 'create new contact with original sender' do
|
||||
described_subject
|
||||
email_sender = Mail::Address.new(group_sender_support_mail.mail[:from].value).name
|
||||
|
||||
expect(conversation.contact.email).to eq(group_sender_support_mail.mail['X-Original-Sender'].value)
|
||||
expect(conversation.contact.name).to eq(email_sender)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when mail has in reply to email' do
|
||||
let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
|
||||
let(:described_subject) { described_class.receive reply_mail_without_uuid }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
|
||||
before do
|
||||
email_channel
|
||||
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
|
||||
end
|
||||
|
||||
it 'create channel with reply to mail' do
|
||||
described_subject
|
||||
conversation_1 = Conversation.last
|
||||
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
|
||||
end
|
||||
|
||||
it 'append message to email conversation with same in reply to' do
|
||||
described_subject
|
||||
conversation_1 = Conversation.last
|
||||
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
|
||||
expect(conversation_1.messages.count).to eq(1)
|
||||
|
||||
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
|
||||
reply_mail_without_uuid.mail['Message-Id'] = '0CB459E0-0336-41DA-BC88-E6E28C697SFC@chatwoot.com'
|
||||
|
||||
described_class.receive reply_mail_without_uuid
|
||||
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
|
||||
expect(conversation_1.messages.count).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Sender with reply_to email address' do
|
||||
let(:reply_to_mail) { create_inbound_email_from_fixture('reply_to.eml') }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
|
||||
it 'prefer reply-to over from address' do
|
||||
email_channel
|
||||
described_class.receive reply_to_mail
|
||||
|
||||
conversation_1 = Conversation.last
|
||||
email = conversation_1.messages.last.content_attributes['email']
|
||||
|
||||
expect(reply_to_mail.mail['From'].value).to be_present
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(reply_to_mail.mail['Reply-To'].value).to include(email['from'][0])
|
||||
expect(reply_to_mail.mail['Reply-To'].value).to include(conversation_1.contact.email)
|
||||
expect(reply_to_mail.mail['From'].value).not_to include(conversation_1.contact.email)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when mail part is not present' do
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support_1.eml') }
|
||||
let(:only_text) { create_inbound_email_from_fixture('only_text.eml') }
|
||||
let(:only_html) { create_inbound_email_from_fixture('only_html.eml') }
|
||||
let(:only_attachments) { create_inbound_email_from_fixture('only_attachments.eml') }
|
||||
let(:html_and_attachments) { create_inbound_email_from_fixture('html_and_attachments.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail }
|
||||
|
||||
it 'Considers raw html mail body' do
|
||||
described_subject
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content_attributes['email']['html_content']['reply']).to include(
|
||||
<<~BODY.chomp
|
||||
Hi,
|
||||
|
||||
We are providing you platform from here you can sell paid posts on your website.
|
||||
|
||||
Chatwoot | CS team | [C](https://d33wubrfki0l68.cloudfront.net/973467c532160fd8b940300a43fa85fa2d060307/dc9a0/static/brand-73f58cdefae282ae74cebfa74c1d7003.svg)
|
||||
|
||||
Skype: live:.cid.something
|
||||
|
||||
[]
|
||||
BODY
|
||||
)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('Get Paid to post an article')
|
||||
end
|
||||
|
||||
it 'Considers only text body' do
|
||||
described_class.receive only_text
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to eq('text only mail')
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test text only mail')
|
||||
end
|
||||
|
||||
it 'Considers only html body' do
|
||||
described_class.receive only_html
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to eq(
|
||||
<<~BODY.chomp
|
||||
This is html only mail
|
||||
BODY
|
||||
)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test html only mail')
|
||||
end
|
||||
|
||||
it 'Considers only attachments' do
|
||||
described_class.receive only_attachments
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to be_nil
|
||||
expect(conversation.messages.last.attachments.count).to eq(1)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('only attachments')
|
||||
end
|
||||
|
||||
it 'Considers html and attachments' do
|
||||
described_class.receive html_and_attachments
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to eq('This is html and attachments only mail')
|
||||
expect(conversation.messages.last.attachments.count).to eq(1)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('attachment with html')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when BCC processing is disabled for account' do
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('SKIP_INCOMING_BCC_PROCESSING', '').and_return(account.id.to_s)
|
||||
end
|
||||
|
||||
it 'does not process BCC-only emails' do
|
||||
bcc_mail = create_inbound_email_from_fixture('support.eml')
|
||||
bcc_mail.mail['to'] = nil
|
||||
bcc_mail.mail['bcc'] = 'care@example.com'
|
||||
|
||||
expect { described_class.receive bcc_mail }.to raise_error('Email channel/inbox not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,133 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinder do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
describe '#find' do
|
||||
context 'when receiver uuid strategy finds conversation' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
finder = described_class.new(mail)
|
||||
expect(finder.find).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'logs which strategy succeeded' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
finder = described_class.new(mail)
|
||||
finder.find
|
||||
|
||||
expect(Rails.logger).to have_received(:info).with('Conversation found via receiver_uuid_strategy strategy')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to strategy finds conversation' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
finder = described_class.new(mail)
|
||||
expect(finder.find).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'logs which strategy succeeded' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
finder = described_class.new(mail)
|
||||
finder.find
|
||||
|
||||
expect(Rails.logger).to have_received(:info).with('Conversation found via in_reply_to_strategy strategy')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references strategy finds conversation' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
finder = described_class.new(mail)
|
||||
expect(finder.find).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'logs which strategy succeeded' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
finder = described_class.new(mail)
|
||||
finder.find
|
||||
|
||||
expect(Rails.logger).to have_received(:info).with('Conversation found via references_strategy strategy')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no strategy finds conversation' do
|
||||
# With NewConversationStrategy in default strategies, this scenario only happens
|
||||
# when using custom strategies that exclude NewConversationStrategy
|
||||
let(:finding_strategies) do
|
||||
[
|
||||
Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy,
|
||||
Mailbox::ConversationFinderStrategies::InReplyToStrategy,
|
||||
Mailbox::ConversationFinderStrategies::ReferencesStrategy
|
||||
]
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
finder = described_class.new(mail, strategies: finding_strategies)
|
||||
expect(finder.find).to be_nil
|
||||
end
|
||||
|
||||
it 'logs that no conversation was found' do
|
||||
allow(Rails.logger).to receive(:error)
|
||||
finder = described_class.new(mail, strategies: finding_strategies)
|
||||
finder.find
|
||||
|
||||
expect(Rails.logger).to have_received(:error).with('No conversation found via any strategy (NewConversationStrategy missing?)')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with custom strategies' do
|
||||
let(:custom_strategy_class) do
|
||||
Class.new(Mailbox::ConversationFinderStrategies::BaseStrategy) do
|
||||
def find
|
||||
# Always return nil for testing
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses provided strategies instead of defaults' do
|
||||
finder = described_class.new(mail, strategies: [custom_strategy_class])
|
||||
expect(finder.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with strategy execution order' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
|
||||
# Set up mail so all strategies could match
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
|
||||
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/456@example.com'
|
||||
end
|
||||
|
||||
it 'returns conversation from first matching strategy' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
finder = described_class.new(mail)
|
||||
result = finder.find
|
||||
|
||||
expect(result).to eq(conversation)
|
||||
# Should only log the first strategy that succeeded (ReceiverUuidStrategy)
|
||||
expect(Rails.logger).to have_received(:info).once.with('Conversation found via receiver_uuid_strategy strategy')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,118 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinderStrategies::InReplyToStrategy do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
describe '#find' do
|
||||
context 'when in_reply_to has message-specific pattern' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'extracts UUID and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to has conversation fallback pattern' do
|
||||
before do
|
||||
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
mail.in_reply_to = "account/#{account.id}/conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com"
|
||||
end
|
||||
|
||||
it 'extracts UUID and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to matches message source_id' do
|
||||
let(:message) do
|
||||
conversation.messages.create!(
|
||||
source_id: 'original-message-id@example.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Original message'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
message # Create the message
|
||||
mail.in_reply_to = 'original-message-id@example.com'
|
||||
end
|
||||
|
||||
it 'finds conversation from message source_id' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to has multiple values' do
|
||||
let(:message) do
|
||||
conversation.messages.create!(
|
||||
source_id: 'message-123@example.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Test message'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
message # Create the message
|
||||
mail.in_reply_to = ['some-other-id@example.com', 'message-123@example.com']
|
||||
end
|
||||
|
||||
it 'finds conversation from any in_reply_to value' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to is blank' do
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to does not match any pattern or source_id' do
|
||||
before do
|
||||
mail.in_reply_to = 'random-message-id@gmail.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when UUID exists but conversation does not' do
|
||||
before do
|
||||
mail.in_reply_to = 'conversation/99999999-9999-9999-9999-999999999999/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with malformed in_reply_to pattern' do
|
||||
before do
|
||||
mail.in_reply_to = 'conversation/not-a-uuid/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinderStrategies::NewConversationStrategy do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
before do
|
||||
mail.to = [email_channel.email]
|
||||
mail.from = 'sender@example.com'
|
||||
mail.subject = 'Test Subject'
|
||||
mail.message_id = '<test@example.com>'
|
||||
end
|
||||
|
||||
describe '#find' do
|
||||
context 'when channel is found' do
|
||||
context 'with new contact' do
|
||||
it 'builds a new conversation with new contact' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
expect do
|
||||
conversation = strategy.find
|
||||
expect(conversation).to be_a(Conversation)
|
||||
expect(conversation.new_record?).to be(true) # Not persisted yet
|
||||
expect(conversation.inbox).to eq(email_channel.inbox)
|
||||
expect(conversation.account).to eq(account)
|
||||
end.to not_change(Conversation, :count) # No conversation created yet
|
||||
.and change(Contact, :count).by(1) # Contact is created
|
||||
.and change(ContactInbox, :count).by(1)
|
||||
end
|
||||
|
||||
it 'sets conversation attributes correctly' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.additional_attributes['source']).to eq('email')
|
||||
expect(conversation.additional_attributes['mail_subject']).to eq('Test Subject')
|
||||
expect(conversation.additional_attributes['initiated_at']).to have_key('timestamp')
|
||||
end
|
||||
|
||||
it 'sets contact attributes correctly' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.contact.email).to eq('sender@example.com')
|
||||
expect(conversation.contact.name).to eq('sender')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with existing contact' do
|
||||
let!(:existing_contact) { create(:contact, email: 'sender@example.com', account: account) }
|
||||
|
||||
before do
|
||||
create(:contact_inbox, contact: existing_contact, inbox: email_channel.inbox)
|
||||
end
|
||||
|
||||
it 'builds conversation with existing contact' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
expect do
|
||||
conversation = strategy.find
|
||||
expect(conversation).to be_a(Conversation)
|
||||
expect(conversation.new_record?).to be(true) # Not persisted yet
|
||||
expect(conversation.contact).to eq(existing_contact)
|
||||
end.to not_change(Conversation, :count) # No conversation created yet
|
||||
.and not_change(Contact, :count)
|
||||
.and not_change(ContactInbox, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has In-Reply-To header' do
|
||||
before do
|
||||
mail['In-Reply-To'] = '<previous-message@example.com>'
|
||||
end
|
||||
|
||||
it 'stores in_reply_to in additional_attributes' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.additional_attributes['in_reply_to']).to eq('<previous-message@example.com>')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail is auto reply' do
|
||||
before do
|
||||
mail['X-Autoreply'] = 'yes'
|
||||
end
|
||||
|
||||
it 'marks conversation as auto_reply' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.additional_attributes['auto_reply']).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when sender has name in From header' do
|
||||
before do
|
||||
mail.from = 'John Doe <john@example.com>'
|
||||
end
|
||||
|
||||
it 'uses sender name from mail' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.contact.name).to eq('John Doe')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is not found' do
|
||||
before do
|
||||
mail.to = ['nonexistent@example.com']
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
expect do
|
||||
result = strategy.find
|
||||
expect(result).to be_nil
|
||||
end.not_to change(Conversation, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact creation fails' do
|
||||
before do
|
||||
builder = instance_double(ContactInboxWithContactBuilder)
|
||||
allow(ContactInboxWithContactBuilder).to receive(:new).and_return(builder)
|
||||
allow(builder).to receive(:perform).and_raise(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
|
||||
it 'rolls back the transaction' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
expect do
|
||||
strategy.find
|
||||
end.to raise_error(ActiveRecord::RecordInvalid)
|
||||
.and not_change(Conversation, :count)
|
||||
.and not_change(Contact, :count)
|
||||
.and not_change(ContactInbox, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation creation fails' do
|
||||
before do
|
||||
# Make conversation build fail with invalid attributes
|
||||
allow(Conversation).to receive(:new).and_return(Conversation.new)
|
||||
end
|
||||
|
||||
it 'returns invalid conversation object' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
conversation = strategy.find
|
||||
expect(conversation).to be_a(Conversation)
|
||||
expect(conversation.new_record?).to be(true)
|
||||
expect(conversation.valid?).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
describe '#find' do
|
||||
context 'when mail has valid reply+uuid format' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has uppercase UUID' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012@EXAMPLE.COM'
|
||||
end
|
||||
|
||||
it 'returns the conversation (case-insensitive matching)' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has multiple recipients with valid UUID' do
|
||||
before do
|
||||
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
mail.to = ['other@example.com', 'reply+aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com']
|
||||
end
|
||||
|
||||
it 'extracts UUID from any recipient' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when UUID does not exist in database' do
|
||||
before do
|
||||
mail.to = 'reply+99999999-9999-9999-9999-999999999999@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has no recipients' do
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail recipient has malformed UUID' do
|
||||
before do
|
||||
mail.to = 'reply+not-a-valid-uuid@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail recipient has no reply+ prefix' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test+12345678-1234-1234-1234-123456789012@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail recipient has additional text after UUID' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012-extra@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil (UUID must be exact)' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,208 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinderStrategies::ReferencesStrategy do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
describe '#find' do
|
||||
context 'when references has message-specific pattern' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'extracts UUID and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references has conversation fallback pattern' do
|
||||
before do
|
||||
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = "account/#{account.id}/conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com"
|
||||
end
|
||||
|
||||
it 'extracts UUID and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references matches message source_id' do
|
||||
let(:message) do
|
||||
conversation.messages.create!(
|
||||
source_id: 'original-message-id@example.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Original message'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
message # Create the message
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'original-message-id@example.com'
|
||||
end
|
||||
|
||||
it 'finds conversation from message source_id' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references has multiple values' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = [
|
||||
'some-random-message@gmail.com',
|
||||
'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com',
|
||||
'another-message@outlook.com'
|
||||
]
|
||||
end
|
||||
|
||||
it 'finds conversation from any reference' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references is blank' do
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references does not match any pattern or source_id' do
|
||||
before do
|
||||
mail.references = 'random-message-id@gmail.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with channel validation' do
|
||||
context 'when conversation belongs to the correct channel' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation belongs to a different channel' do
|
||||
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
|
||||
let(:other_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
inbox: other_email_channel.inbox,
|
||||
account: account
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
# Mail is addressed to test@example.com but references conversation from other@example.com
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil (prevents cross-channel hijacking)' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel cannot be determined from mail' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'unknown@example.com' # Email not associated with any channel
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has multiple recipients including correct channel' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = ['other@example.com', 'test@example.com']
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'finds the correct channel and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when UUID exists but conversation does not' do
|
||||
before do
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/99999999-9999-9999-9999-999999999999/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with malformed references pattern' do
|
||||
before do
|
||||
mail.references = 'conversation/not-a-uuid/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when first reference fails channel validation but second succeeds' do
|
||||
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
|
||||
let(:other_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
inbox: other_email_channel.inbox,
|
||||
account: account
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = [
|
||||
'conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@example.com', # Wrong channel
|
||||
'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com' # Correct channel
|
||||
]
|
||||
end
|
||||
|
||||
it 'skips invalid reference and returns conversation from valid reference' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -248,3 +248,9 @@ contact_conversations_response:
|
||||
$ref: ./resource/contact_conversations_response.yml
|
||||
contactable_inboxes_response:
|
||||
$ref: ./resource/contactable_inboxes_response.yml
|
||||
reporting_event:
|
||||
$ref: ./resource/reporting_event.yml
|
||||
reporting_event_meta:
|
||||
$ref: ./resource/reporting_event_meta.yml
|
||||
reporting_events_list_response:
|
||||
$ref: ./resource/reporting_events_list_response.yml
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
description: ID of the reporting event
|
||||
name:
|
||||
type: string
|
||||
description: Name of the event (e.g., first_response, resolution, reply_time)
|
||||
value:
|
||||
type: number
|
||||
format: double
|
||||
description: Value of the metric in seconds
|
||||
value_in_business_hours:
|
||||
type: number
|
||||
format: double
|
||||
description: Value of the metric in seconds, calculated only for business hours
|
||||
event_start_time:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The timestamp when the event started
|
||||
event_end_time:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The timestamp when the event ended
|
||||
account_id:
|
||||
type: number
|
||||
description: ID of the account
|
||||
conversation_id:
|
||||
type: number
|
||||
nullable: true
|
||||
description: ID of the conversation
|
||||
inbox_id:
|
||||
type: number
|
||||
nullable: true
|
||||
description: ID of the inbox
|
||||
user_id:
|
||||
type: number
|
||||
nullable: true
|
||||
description: ID of the user/agent
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The timestamp when the reporting event was created
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The timestamp when the reporting event was last updated
|
||||
@@ -0,0 +1,11 @@
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
description: Total number of reporting events
|
||||
current_page:
|
||||
type: integer
|
||||
description: Current page number
|
||||
total_pages:
|
||||
type: integer
|
||||
description: Total number of pages
|
||||
@@ -0,0 +1,10 @@
|
||||
type: object
|
||||
properties:
|
||||
meta:
|
||||
$ref: '#/components/schemas/reporting_event_meta'
|
||||
description: Metadata about the reporting events list response
|
||||
payload:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/reporting_event'
|
||||
description: List of reporting events
|
||||
@@ -1,6 +1,6 @@
|
||||
post:
|
||||
tags:
|
||||
- Contact
|
||||
- Contacts
|
||||
operationId: contactInboxCreation
|
||||
description: Create a contact inbox record for an inbox
|
||||
summary: Create contact inbox
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
get:
|
||||
tags:
|
||||
- Contact
|
||||
- Contacts
|
||||
operationId: contactableInboxesGet
|
||||
description: Get List of contactable Inboxes
|
||||
summary: Get Contactable Inboxes
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
tags:
|
||||
- Conversations
|
||||
operationId: get-conversation-reporting-events
|
||||
summary: Conversation Reporting Events
|
||||
security:
|
||||
- userApiKey: []
|
||||
description: Get reporting events for a specific conversation. This endpoint returns events such as first response time, resolution time, and other metrics for the conversation, sorted by creation time in ascending order.
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/reporting_event'
|
||||
description: Array of reporting events for the conversation
|
||||
'403':
|
||||
description: Access denied
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/bad_request_error'
|
||||
'404':
|
||||
description: Conversation not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/bad_request_error'
|
||||
@@ -0,0 +1,47 @@
|
||||
tags:
|
||||
- Reports
|
||||
operationId: get-account-reporting-events
|
||||
summary: Account Reporting Events
|
||||
security:
|
||||
- userApiKey: []
|
||||
description: Get paginated reporting events for the account. This endpoint returns reporting events such as first response time, resolution time, and other metrics. Only administrators can access this endpoint. Results are paginated with 25 items per page.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/page'
|
||||
- in: query
|
||||
name: since
|
||||
schema:
|
||||
type: string
|
||||
description: The timestamp from where events should start (Unix timestamp in seconds)
|
||||
- in: query
|
||||
name: until
|
||||
schema:
|
||||
type: string
|
||||
description: The timestamp from where events should stop (Unix timestamp in seconds)
|
||||
- in: query
|
||||
name: inbox_id
|
||||
schema:
|
||||
type: number
|
||||
description: Filter events by inbox ID
|
||||
- in: query
|
||||
name: user_id
|
||||
schema:
|
||||
type: number
|
||||
description: Filter events by user/agent ID
|
||||
- in: query
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
description: Filter events by event name (e.g., first_response, resolution, reply_time)
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/reporting_events_list_response'
|
||||
'403':
|
||||
description: Access denied - Only administrators can access this endpoint
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/bad_request_error'
|
||||
@@ -389,6 +389,15 @@
|
||||
post:
|
||||
$ref: ./application/conversation/labels/create.yml
|
||||
|
||||
# Conversation Reporting Events
|
||||
|
||||
/api/v1/accounts/{account_id}/conversations/{conversation_id}/reporting_events:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/account_id'
|
||||
- $ref: '#/components/parameters/conversation_id'
|
||||
get:
|
||||
$ref: ./application/conversation/reporting_events.yml
|
||||
|
||||
# Inboxes
|
||||
/api/v1/accounts/{account_id}/inboxes:
|
||||
$ref: ./application/inboxes/index.yml
|
||||
@@ -535,6 +544,13 @@
|
||||
|
||||
### Reports
|
||||
|
||||
# Account Reporting Events
|
||||
/api/v1/accounts/{account_id}/reporting_events:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/account_id'
|
||||
get:
|
||||
$ref: ./application/reporting_events/index.yml
|
||||
|
||||
# List
|
||||
/api/v2/accounts/{account_id}/reports:
|
||||
parameters:
|
||||
|
||||
+242
-2
@@ -3281,7 +3281,7 @@
|
||||
"/api/v1/accounts/{account_id}/contacts/{id}/contact_inboxes": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Contact"
|
||||
"Contacts"
|
||||
],
|
||||
"operationId": "contactInboxCreation",
|
||||
"description": "Create a contact inbox record for an inbox",
|
||||
@@ -3366,7 +3366,7 @@
|
||||
"/api/v1/accounts/{account_id}/contacts/{id}/contactable_inboxes": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Contact"
|
||||
"Contacts"
|
||||
],
|
||||
"operationId": "contactableInboxesGet",
|
||||
"description": "Get List of contactable Inboxes",
|
||||
@@ -5140,6 +5140,65 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/accounts/{account_id}/conversations/{conversation_id}/reporting_events": {
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/account_id"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/conversation_id"
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Conversations"
|
||||
],
|
||||
"operationId": "get-conversation-reporting-events",
|
||||
"summary": "Conversation Reporting Events",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
}
|
||||
],
|
||||
"description": "Get reporting events for a specific conversation. This endpoint returns events such as first response time, resolution time, and other metrics for the conversation, sorted by creation time in ascending order.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/reporting_event"
|
||||
},
|
||||
"description": "Array of reporting events for the conversation"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Access denied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Conversation not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/accounts/{account_id}/inboxes": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -7311,6 +7370,93 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/accounts/{account_id}/reporting_events": {
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/account_id"
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Reports"
|
||||
],
|
||||
"operationId": "get-account-reporting-events",
|
||||
"summary": "Account Reporting Events",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
}
|
||||
],
|
||||
"description": "Get paginated reporting events for the account. This endpoint returns reporting events such as first response time, resolution time, and other metrics. Only administrators can access this endpoint. Results are paginated with 25 items per page.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/page"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "since",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The timestamp from where events should start (Unix timestamp in seconds)"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "until",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The timestamp from where events should stop (Unix timestamp in seconds)"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "inbox_id",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
},
|
||||
"description": "Filter events by inbox ID"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "user_id",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
},
|
||||
"description": "Filter events by user/agent ID"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "name",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Filter events by event name (e.g., first_response, resolution, reply_time)"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/reporting_events_list_response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Access denied - Only administrators can access this endpoint",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v2/accounts/{account_id}/reports": {
|
||||
"parameters": [
|
||||
{
|
||||
@@ -12066,6 +12212,100 @@
|
||||
"description": "List of contactable inboxes for the contact"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_event": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "ID of the reporting event"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the event (e.g., first_response, resolution, reply_time)"
|
||||
},
|
||||
"value": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Value of the metric in seconds"
|
||||
},
|
||||
"value_in_business_hours": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Value of the metric in seconds, calculated only for business hours"
|
||||
},
|
||||
"event_start_time": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the event started"
|
||||
},
|
||||
"event_end_time": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the event ended"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "number",
|
||||
"description": "ID of the account"
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the inbox"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the user/agent"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the reporting event was created"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the reporting event was last updated"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_event_meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Total number of reporting events"
|
||||
},
|
||||
"current_page": {
|
||||
"type": "integer",
|
||||
"description": "Current page number"
|
||||
},
|
||||
"total_pages": {
|
||||
"type": "integer",
|
||||
"description": "Total number of pages"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_events_list_response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/reporting_event_meta"
|
||||
},
|
||||
"payload": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/reporting_event"
|
||||
},
|
||||
"description": "List of reporting events"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
|
||||
@@ -1821,6 +1821,152 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/accounts/{account_id}/contacts/{id}/contact_inboxes": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Contacts"
|
||||
],
|
||||
"operationId": "contactInboxCreation",
|
||||
"description": "Create a contact inbox record for an inbox",
|
||||
"summary": "Create contact inbox",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/account_id"
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
},
|
||||
"description": "ID of the contact",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"inbox_id"
|
||||
],
|
||||
"properties": {
|
||||
"inbox_id": {
|
||||
"type": "number",
|
||||
"description": "The ID of the inbox",
|
||||
"example": 1
|
||||
},
|
||||
"source_id": {
|
||||
"type": "string",
|
||||
"description": "Contact Inbox Source Id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/contact_inboxes"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Incorrect payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/accounts/{account_id}/contacts/{id}/contactable_inboxes": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Contacts"
|
||||
],
|
||||
"operationId": "contactableInboxesGet",
|
||||
"description": "Get List of contactable Inboxes",
|
||||
"summary": "Get Contactable Inboxes",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/account_id"
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
},
|
||||
"description": "ID of the contact",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/contactable_inboxes_response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Incorrect payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/accounts/{account_id}/automation_rules": {
|
||||
"parameters": [
|
||||
{
|
||||
@@ -3537,6 +3683,65 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/accounts/{account_id}/conversations/{conversation_id}/reporting_events": {
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/account_id"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/conversation_id"
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Conversations"
|
||||
],
|
||||
"operationId": "get-conversation-reporting-events",
|
||||
"summary": "Conversation Reporting Events",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
}
|
||||
],
|
||||
"description": "Get reporting events for a specific conversation. This endpoint returns events such as first response time, resolution time, and other metrics for the conversation, sorted by creation time in ascending order.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/reporting_event"
|
||||
},
|
||||
"description": "Array of reporting events for the conversation"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Access denied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Conversation not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/accounts/{account_id}/inboxes": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -5708,6 +5913,93 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/accounts/{account_id}/reporting_events": {
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/account_id"
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Reports"
|
||||
],
|
||||
"operationId": "get-account-reporting-events",
|
||||
"summary": "Account Reporting Events",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
}
|
||||
],
|
||||
"description": "Get paginated reporting events for the account. This endpoint returns reporting events such as first response time, resolution time, and other metrics. Only administrators can access this endpoint. Results are paginated with 25 items per page.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/page"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "since",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The timestamp from where events should start (Unix timestamp in seconds)"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "until",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The timestamp from where events should stop (Unix timestamp in seconds)"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "inbox_id",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
},
|
||||
"description": "Filter events by inbox ID"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "user_id",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
},
|
||||
"description": "Filter events by user/agent ID"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "name",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Filter events by event name (e.g., first_response, resolution, reply_time)"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/reporting_events_list_response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Access denied - Only administrators can access this endpoint",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v2/accounts/{account_id}/reports": {
|
||||
"parameters": [
|
||||
{
|
||||
@@ -10427,6 +10719,100 @@
|
||||
"description": "List of contactable inboxes for the contact"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_event": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "ID of the reporting event"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the event (e.g., first_response, resolution, reply_time)"
|
||||
},
|
||||
"value": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Value of the metric in seconds"
|
||||
},
|
||||
"value_in_business_hours": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Value of the metric in seconds, calculated only for business hours"
|
||||
},
|
||||
"event_start_time": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the event started"
|
||||
},
|
||||
"event_end_time": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the event ended"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "number",
|
||||
"description": "ID of the account"
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the inbox"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the user/agent"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the reporting event was created"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the reporting event was last updated"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_event_meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Total number of reporting events"
|
||||
},
|
||||
"current_page": {
|
||||
"type": "integer",
|
||||
"description": "Current page number"
|
||||
},
|
||||
"total_pages": {
|
||||
"type": "integer",
|
||||
"description": "Total number of pages"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_events_list_response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/reporting_event_meta"
|
||||
},
|
||||
"payload": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/reporting_event"
|
||||
},
|
||||
"description": "List of reporting events"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
|
||||
@@ -5019,6 +5019,100 @@
|
||||
"description": "List of contactable inboxes for the contact"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_event": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "ID of the reporting event"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the event (e.g., first_response, resolution, reply_time)"
|
||||
},
|
||||
"value": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Value of the metric in seconds"
|
||||
},
|
||||
"value_in_business_hours": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Value of the metric in seconds, calculated only for business hours"
|
||||
},
|
||||
"event_start_time": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the event started"
|
||||
},
|
||||
"event_end_time": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the event ended"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "number",
|
||||
"description": "ID of the account"
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the inbox"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the user/agent"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the reporting event was created"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the reporting event was last updated"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_event_meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Total number of reporting events"
|
||||
},
|
||||
"current_page": {
|
||||
"type": "integer",
|
||||
"description": "Current page number"
|
||||
},
|
||||
"total_pages": {
|
||||
"type": "integer",
|
||||
"description": "Total number of pages"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_events_list_response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/reporting_event_meta"
|
||||
},
|
||||
"payload": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/reporting_event"
|
||||
},
|
||||
"description": "List of reporting events"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
|
||||
@@ -4434,6 +4434,100 @@
|
||||
"description": "List of contactable inboxes for the contact"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_event": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "ID of the reporting event"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the event (e.g., first_response, resolution, reply_time)"
|
||||
},
|
||||
"value": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Value of the metric in seconds"
|
||||
},
|
||||
"value_in_business_hours": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Value of the metric in seconds, calculated only for business hours"
|
||||
},
|
||||
"event_start_time": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the event started"
|
||||
},
|
||||
"event_end_time": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the event ended"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "number",
|
||||
"description": "ID of the account"
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the inbox"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the user/agent"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the reporting event was created"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the reporting event was last updated"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_event_meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Total number of reporting events"
|
||||
},
|
||||
"current_page": {
|
||||
"type": "integer",
|
||||
"description": "Current page number"
|
||||
},
|
||||
"total_pages": {
|
||||
"type": "integer",
|
||||
"description": "Total number of pages"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_events_list_response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/reporting_event_meta"
|
||||
},
|
||||
"payload": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/reporting_event"
|
||||
},
|
||||
"description": "List of reporting events"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
|
||||
@@ -5195,6 +5195,100 @@
|
||||
"description": "List of contactable inboxes for the contact"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_event": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "ID of the reporting event"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the event (e.g., first_response, resolution, reply_time)"
|
||||
},
|
||||
"value": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Value of the metric in seconds"
|
||||
},
|
||||
"value_in_business_hours": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Value of the metric in seconds, calculated only for business hours"
|
||||
},
|
||||
"event_start_time": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the event started"
|
||||
},
|
||||
"event_end_time": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the event ended"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "number",
|
||||
"description": "ID of the account"
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the inbox"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "ID of the user/agent"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the reporting event was created"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp when the reporting event was last updated"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_event_meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Total number of reporting events"
|
||||
},
|
||||
"current_page": {
|
||||
"type": "integer",
|
||||
"description": "Current page number"
|
||||
},
|
||||
"total_pages": {
|
||||
"type": "integer",
|
||||
"description": "Total number of pages"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reporting_events_list_response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/reporting_event_meta"
|
||||
},
|
||||
"payload": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/reporting_event"
|
||||
},
|
||||
"description": "List of reporting events"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
|
||||
Reference in New Issue
Block a user