Merge pull request #41 from fazer-ai/chore/merge-upstream

Chore/merge upstream
This commit is contained in:
Gabriel Jablonski 2025-05-06 16:51:31 -03:00 committed by GitHub
commit f90394fb2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 827 additions and 156 deletions

View File

@ -2,10 +2,15 @@ class Api::V1::Widget::CampaignsController < Api::V1::Widget::BaseController
skip_before_action :set_contact
def index
@campaigns = @web_widget
.inbox
.campaigns
.where(enabled: true, account_id: @web_widget.inbox.account_id)
.includes(:sender)
account = @web_widget.inbox.account
@campaigns = if account.feature_enabled?('campaigns')
@web_widget
.inbox
.campaigns
.where(enabled: true, account_id: account.id)
.includes(:sender)
else
[]
end
end
end

View File

@ -65,7 +65,7 @@ watch(() => props.messages.length, scrollToBottom);
class="max-w-[80%] rounded-lg p-3 text-sm"
:class="getMessageStyle(message.sender)"
>
<div v-html="formatMessage(message.content)" />
<div class="break-words" v-html="formatMessage(message.content)" />
</div>
</div>
</div>

View File

@ -214,7 +214,7 @@ watch(
v-model="state.instructions"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
:message="formErrors.instructions"
:max-length="2000"
:max-length="20000"
:message-type="formErrors.instructions ? 'error' : 'info'"
/>

View File

@ -1,5 +1,6 @@
<script setup>
import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@ -39,6 +40,8 @@ const props = defineProps({
const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const { t } = useI18n();
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => {
@ -47,7 +50,7 @@ const sendMessage = message => {
};
const useSuggestion = opt => {
emit('sendMessage', opt.prompt);
emit('sendMessage', t(opt.prompt));
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
@ -66,16 +69,16 @@ const scrollToBottom = async () => {
const promptOptions = [
{
label: 'Summarize this conversation',
prompt: `Summarize the key points discussed between the customer and the support agent, including the customer's concerns, questions, and the solutions or responses provided by the support agent`,
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'Suggest an answer',
prompt: `Analyze the customers inquiry, and draft a response that effectively addresses their concerns or questions. Ensure the reply is clear, concise, and provides helpful information.`,
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'Rate this conversation',
prompt: `Review the conversation to see how well it meets the customers needs. Share a rating out of 5 based on tone, clarity, and effectiveness.`,
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
];
@ -89,7 +92,7 @@ watch(
</script>
<template>
<div class="flex flex-col h-full text-sm leading-6 tracking-tight">
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
@ -121,7 +124,7 @@ watch(
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)"
>
<span>{{ prompt.label }}</span>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>

View File

@ -117,7 +117,7 @@ const props = defineProps({
},
conversationId: { type: Number, required: true },
createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
currentUserId: { type: Number, required: true },
currentUserId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
groupWithNext: { type: Boolean, default: false },
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
@ -173,7 +173,10 @@ const variant = computed(() => {
return variants[props.messageType] || MESSAGE_VARIANTS.USER;
});
const isMyMessage = computed(() => {
const isBotOrAgentMessage = computed(() => {
if (props.messageType === MESSAGE_TYPES.ACTIVITY) {
return false;
}
// if an outgoing message is still processing, then it's definitely a
// message sent by the current user
if (
@ -186,13 +189,10 @@ const isMyMessage = computed(() => {
const senderType = props.senderType ?? props.sender?.type;
if (!senderType || !senderId) {
return false;
return true;
}
return (
senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase() &&
props.currentUserId === senderId
);
return senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase();
});
/**
@ -200,7 +200,7 @@ const isMyMessage = computed(() => {
* @returns {import('vue').ComputedRef<'left'|'right'|'center'>} The computed orientation
*/
const orientation = computed(() => {
if (isMyMessage.value) {
if (isBotOrAgentMessage.value) {
return ORIENTATION.RIGHT;
}
@ -221,8 +221,8 @@ const flexOrientationClass = computed(() => {
const gridClass = computed(() => {
const map = {
[ORIENTATION.LEFT]: 'grid grid-cols-[24px_1fr]',
[ORIENTATION.RIGHT]: 'grid grid-cols-1fr',
[ORIENTATION.LEFT]: 'grid grid-cols-1fr',
[ORIENTATION.RIGHT]: 'grid grid-cols-[1fr_24px]',
};
return map[orientation.value];
@ -231,13 +231,13 @@ const gridClass = computed(() => {
const gridTemplate = computed(() => {
const map = {
[ORIENTATION.LEFT]: `
"avatar bubble"
"spacer meta"
`,
[ORIENTATION.RIGHT]: `
"bubble"
"meta"
`,
[ORIENTATION.RIGHT]: `
"bubble avatar"
"meta spacer"
`,
};
return map[orientation.value];
@ -251,7 +251,7 @@ const shouldGroupWithNext = computed(() => {
const shouldShowAvatar = computed(() => {
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return false;
if (orientation.value === ORIENTATION.RIGHT) return false;
if (orientation.value === ORIENTATION.LEFT) return false;
return true;
});
@ -394,23 +394,29 @@ function handleReplyTo() {
}
const avatarInfo = computed(() => {
if (!props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT) {
// If no sender, return bot info
if (!props.sender) {
return {
name: t('CONVERSATION.BOT'),
src: '',
};
}
if (props.sender) {
const { sender } = props;
const { name, type, avatarUrl, thumbnail } = sender || {};
// If sender type is agent bot, use avatarUrl
if (type === SENDER_TYPES.AGENT_BOT) {
return {
name: props.sender.name,
src: props.sender?.thumbnail,
name: name ?? '',
src: avatarUrl ?? '',
};
}
// For all other senders, use thumbnail
return {
name: '',
src: '',
name: name ?? '',
src: thumbnail ?? '',
};
});
@ -438,7 +444,7 @@ provideMessageContext({
isPrivate: computed(() => props.private),
variant,
orientation,
isMyMessage,
isBotOrAgentMessage,
shouldGroupWithNext,
});
</script>
@ -470,14 +476,14 @@ provideMessageContext({
'w-full': variant === MESSAGE_VARIANTS.EMAIL,
},
]"
class="gap-x-3"
class="gap-x-2"
:style="{
gridTemplateAreas: gridTemplate,
}"
>
<div
v-if="!shouldGroupWithNext && shouldShowAvatar"
v-tooltip.right-end="avatarTooltip"
v-tooltip.left-end="avatarTooltip"
class="[grid-area:avatar] flex items-end"
>
<Avatar v-bind="avatarInfo" :size="24" />
@ -485,7 +491,8 @@ provideMessageContext({
<div
class="[grid-area:bubble] flex"
:class="{
'ltr:pl-9 rtl:pl-0 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:pl-8 rtl:pr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:pr-8 rtl:pl-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}"
@contextmenu="openContextMenu($event)"

View File

@ -14,7 +14,7 @@ const readableTime = computed(() =>
<template>
<BaseBubble
v-tooltip.top="readableTime"
class="px-2 py-0.5 !rounded-full flex min-w-0 items-center gap-2"
class="px-3 py-1 !rounded-xl flex min-w-0 items-center gap-2"
data-bubble-name="activity"
>
<span v-dompurify-html="content" :title="content" />

View File

@ -98,7 +98,7 @@ const MessageControl = Symbol('MessageControl');
* @property {import('vue').Ref<Sender|null>} [sender=null] - The sender information
* @property {import('vue').ComputedRef<MessageOrientation>} orientation - The visual variant of the message
* @property {import('vue').ComputedRef<MessageVariant>} variant - The visual variant of the message
* @property {import('vue').ComputedRef<boolean>} isMyMessage - Does the message belong to the current user
* @property {import('vue').ComputedRef<boolean>} isBotOrAgentMessage - Does the message belong to the current user
* @property {import('vue').ComputedRef<boolean>} isPrivate - Proxy computed value for private
* @property {import('vue').ComputedRef<boolean>} shouldGroupWithNext - Should group with the next message or not, it is differnt from groupWithNext, this has a bypass for a failed message
*/

View File

@ -15,6 +15,13 @@ const props = defineProps({
type: String,
required: true,
},
subMenuPosition: {
type: String,
default: 'right',
validator: value => {
return ['right', 'left', 'bottom'].includes(value);
},
},
});
const emit = defineEmits(['update:modelValue']);
@ -44,14 +51,21 @@ const handleSelect = value => {
trailing-icon
color="slate"
variant="faded"
class="!w-fit"
class="!w-fit max-w-40"
:class="{ 'dark:!bg-n-alpha-2 !bg-n-slate-9/20': isOpen }"
:label="labelValue"
@click="toggleMenu"
/>
<div
v-if="isOpen"
class="absolute ltr:left-full rtl:right-full select-none max-w-48 ltr:ml-1 rtl:mr-1 flex flex-col gap-1 bg-n-alpha-3 backdrop-blur-[100px] p-1 top-0 shadow-lg rounded-lg border border-n-weak"
class="absolute select-none max-w-64 flex flex-col gap-1 bg-n-alpha-3 backdrop-blur-[100px] p-1 top-0 shadow-lg z-40 rounded-lg border border-n-weak dark:border-n-strong/50"
:class="{
'ltr:left-full rtl:right-full ltr:ml-1 rtl:mr-1':
subMenuPosition === 'right',
'ltr:right-full rtl:left-full ltr:mr-1 rtl:ml-1':
subMenuPosition === 'left',
'top-full mt-1 ltr:right-0 rtl:left-0': subMenuPosition === 'bottom',
}"
>
<Button
v-for="option in options"

View File

@ -1,93 +1,129 @@
<script>
import wootConstants from 'dashboard/constants/globals';
import { mapGetters } from 'vuex';
import FilterItem from './FilterItem.vue';
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import wootConstants from 'dashboard/constants/globals';
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const CHAT_STATUS_FILTER_ITEMS = Object.freeze([
'open',
'resolved',
'pending',
'snoozed',
'all',
]);
defineProps({
isOnExpandedLayout: {
type: Boolean,
required: true,
},
});
const SORT_ORDER_ITEMS = Object.freeze([
'last_activity_at_asc',
'last_activity_at_desc',
'created_at_desc',
'created_at_asc',
'priority_desc',
'priority_asc',
'waiting_since_asc',
'waiting_since_desc',
]);
const emit = defineEmits(['changeFilter']);
export default {
components: {
FilterItem,
NextButton,
},
props: {
isOnExpandedLayout: {
type: Boolean,
required: true,
},
},
emits: ['changeFilter'],
setup() {
const { updateUISettings } = useUISettings();
const store = useStore();
const { t } = useI18n();
return {
updateUISettings,
};
const { updateUISettings } = useUISettings();
const chatStatusFilter = useMapGetter('getChatStatusFilter');
const chatSortFilter = useMapGetter('getChatSortFilter');
const [showActionsDropdown, toggleDropdown] = useToggle();
const currentStatusFilter = computed(() => {
return chatStatusFilter.value || wootConstants.STATUS_TYPE.OPEN;
});
const currentSortBy = computed(() => {
return (
chatSortFilter.value || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC
);
});
const chatStatusOptions = [
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT'),
value: 'open',
},
data() {
return {
showActionsDropdown: false,
chatStatusItems: CHAT_STATUS_FILTER_ITEMS,
chatSortItems: SORT_ORDER_ITEMS,
};
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.resolved.TEXT'),
value: 'resolved',
},
computed: {
...mapGetters({
chatStatusFilter: 'getChatStatusFilter',
chatSortFilter: 'getChatSortFilter',
}),
chatStatus() {
return this.chatStatusFilter || wootConstants.STATUS_TYPE.OPEN;
},
sortFilter() {
return (
this.chatSortFilter || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC
);
},
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.pending.TEXT'),
value: 'pending',
},
methods: {
onTabChange(value) {
this.$emit('changeFilter', value);
this.closeDropdown();
},
toggleDropdown() {
this.showActionsDropdown = !this.showActionsDropdown;
},
closeDropdown() {
this.showActionsDropdown = false;
},
onChangeFilter(value, type) {
this.$emit('changeFilter', value, type);
this.saveSelectedFilter(type, value);
},
saveSelectedFilter(type, value) {
this.updateUISettings({
conversations_filter_by: {
status: type === 'status' ? value : this.chatStatus,
order_by: type === 'sort' ? value : this.sortFilter,
},
});
},
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.snoozed.TEXT'),
value: 'snoozed',
},
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.all.TEXT'),
value: 'all',
},
];
const chatSortOptions = [
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.last_activity_at_asc.TEXT'),
value: 'last_activity_at_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.last_activity_at_desc.TEXT'),
value: 'last_activity_at_desc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.created_at_desc.TEXT'),
value: 'created_at_desc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.created_at_asc.TEXT'),
value: 'created_at_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc.TEXT'),
value: 'priority_desc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_asc.TEXT'),
value: 'priority_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_asc.TEXT'),
value: 'waiting_since_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_desc.TEXT'),
value: 'waiting_since_desc',
},
];
const activeChatStatusLabel = computed(
() =>
chatStatusOptions.find(m => m.value === chatStatusFilter.value)?.label || ''
);
const activeChatSortLabel = computed(
() => chatSortOptions.find(m => m.value === chatSortFilter.value)?.label || ''
);
const saveSelectedFilter = (type, value) => {
updateUISettings({
conversations_filter_by: {
status: type === 'status' ? value : currentStatusFilter.value,
order_by: type === 'sort' ? value : currentSortBy.value,
},
});
};
const handleStatusChange = value => {
emit('changeFilter', value, 'status');
store.dispatch('setChatStatusFilter', value);
saveSelectedFilter('status', value);
};
const handleSortChange = value => {
emit('changeFilter', value, 'sort');
store.dispatch('setChatSortFilter', value);
saveSelectedFilter('sort', value);
};
</script>
@ -99,39 +135,39 @@ export default {
slate
faded
xs
@click="toggleDropdown"
@click="toggleDropdown()"
/>
<div
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
class="mt-1 dropdown-pane dropdown-pane--open !w-52 !p-4 top-6 border !border-n-weak dark:!border-n-weak !bg-n-alpha-3 dark:!bg-n-alpha-3 backdrop-blur-[100px]"
v-on-click-outside="() => toggleDropdown()"
class="mt-1 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 absolute z-40 top-full"
:class="{
'ltr:left-0 rtl:right-0': !isOnExpandedLayout,
'ltr:right-0 rtl:left-0': isOnExpandedLayout,
}"
>
<div class="flex items-center justify-between last:mt-4">
<span class="text-xs font-medium text-n-slate-12">{{
$t('CHAT_LIST.CHAT_SORT.STATUS')
}}</span>
<FilterItem
type="status"
:selected-value="chatStatus"
:items="chatStatusItems"
path-prefix="CHAT_LIST.CHAT_STATUS_FILTER_ITEMS"
@on-change-filter="onChangeFilter"
<div class="flex items-center justify-between last:mt-4 gap-2">
<span class="text-sm truncate text-n-slate-12">
{{ $t('CHAT_LIST.CHAT_SORT.STATUS') }}
</span>
<SelectMenu
:model-value="chatStatusFilter"
:options="chatStatusOptions"
:label="activeChatStatusLabel"
:sub-menu-position="isOnExpandedLayout ? 'left' : 'right'"
@update:model-value="handleStatusChange"
/>
</div>
<div class="flex items-center justify-between last:mt-4">
<span class="text-xs font-medium text-n-slate-12">{{
$t('CHAT_LIST.CHAT_SORT.ORDER_BY')
}}</span>
<FilterItem
type="sort"
:selected-value="sortFilter"
:items="chatSortItems"
path-prefix="CHAT_LIST.SORT_ORDER_ITEMS"
@on-change-filter="onChangeFilter"
<div class="flex items-center justify-between last:mt-4 gap-2">
<span class="text-sm truncate text-n-slate-12">
{{ $t('CHAT_LIST.CHAT_SORT.ORDER_BY') }}
</span>
<SelectMenu
:model-value="chatSortFilter"
:options="chatSortOptions"
:label="activeChatSortLabel"
:sub-menu-position="isOnExpandedLayout ? 'left' : 'right'"
@update:model-value="handleSortChange"
/>
</div>
</div>

View File

@ -342,7 +342,21 @@
"YOU": "You",
"USE": "Use this",
"RESET": "Reset",
"SELECT_ASSISTANT": "Select Assistant"
"SELECT_ASSISTANT": "Select Assistant",
"PROMPTS": {
"SUMMARIZE": {
"LABEL": "Summarize this conversation",
"CONTENT": "Summarize the key points discussed between the customer and the support agent, including the customer's concerns, questions, and the solutions or responses provided by the support agent"
},
"SUGGEST": {
"LABEL": "Suggest an answer",
"CONTENT": "Analyze the customer's inquiry, and draft a response that effectively addresses their concerns or questions. Ensure the reply is clear, concise, and provides helpful information."
},
"RATE": {
"LABEL": "Rate this conversation",
"CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness."
}
}
},
"PLAYGROUND": {
"USER": "You",
@ -400,15 +414,18 @@
},
"NAME": {
"LABEL": "Name",
"PLACEHOLDER": "Enter assistant name"
"PLACEHOLDER": "Enter assistant name",
"ERROR": "The name is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter assistant description"
"PLACEHOLDER": "Enter assistant description",
"ERROR": "The description is required"
},
"PRODUCT_NAME": {
"LABEL": "Product Name",
"PLACEHOLDER": "Enter product name"
"PLACEHOLDER": "Enter product name",
"ERROR": "The product name is required"
},
"WELCOME_MESSAGE": {
"LABEL": "Welcome Message",

View File

@ -334,7 +334,21 @@
"YOU": "You",
"USE": "Use this",
"RESET": "Reset",
"SELECT_ASSISTANT": "Select Assistant"
"SELECT_ASSISTANT": "Select Assistant",
"PROMPTS": {
"SUMMARIZE": {
"LABEL": "ഈ സംവാദം സംക്ഷേപിക്കുക",
"CONTENT": "ഉപഭോക്താവും സഹായ ഏജന്റും തമ്മിലുള്ള സംവാദത്തിലെ പ്രധാന വിഷയങ്ങൾ സംക്ഷേപിക്കുക. ഉപഭോക്താവിന്റെ ആശങ്കകൾ, ചോദ്യങ്ങൾ, ഏജന്റിന്റെ മറുപടികൾ എന്നിവ ഉൾപ്പെടണം."
},
"SUGGEST": {
"LABEL": "ഒരു മറുപടി നിർദ്ദേശിക്കുക",
"CONTENT": "ഉപഭോക്താവിന്റെ ചോദ്യങ്ങൾ വിശകലനം ചെയ്ത്, അവരുടെ ആശങ്കകൾക്കും ചോദ്യങ്ങൾക്കും യോജിച്ചുള്ള ഒരു വ്യക്തവും ഉപകാരപ്രദവുമായ മറുപടി രൂപപ്പെടുത്തുക."
},
"RATE": {
"LABEL": "ഈ സംവാദം റേറ്റുചെയ്യുക",
"CONTENT": "ഉപഭോക്താവിന്റെ ആവശ്യങ്ങൾ എത്രമാത്രം നല്ല രീതിയിൽ നിറവേറ്റിയെന്ന് വിലയിരുത്തുക. സംഭാഷണത്തിന്റെ സ്വരം, വ്യക്തത, കാര്യക്ഷമത എന്നിവയുടെ അടിസ്ഥാനത്തിൽ 5ൽ എത്രയാണെന്ന് റേറ്റുചെയ്യുക."
}
}
},
"PLAYGROUND": {
"USER": "You",

View File

@ -44,7 +44,7 @@ class Instagram::MessageText < Instagram::BaseMessageText
Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}")
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
ChatwootExceptionTracker.new(parsed_response, account: @inbox.account).capture_exception
end
def base_uri

View File

@ -0,0 +1,30 @@
require_relative '../test_data'
namespace :data do
desc 'Generate large, distributed test data'
task generate_distributed_data: :environment do
if Rails.env.production?
puts 'Generating large amounts of data in production can have serious performance implications.'
puts 'Exiting to avoid impacting a live environment.'
exit
end
# Configure logger
Rails.logger = ActiveSupport::Logger.new($stdout)
Rails.logger.formatter = proc do |severity, datetime, _progname, msg|
"#{datetime.strftime('%Y-%m-%d %H:%M:%S.%L')} #{severity}: #{msg}\n"
end
begin
TestData::DatabaseOptimizer.setup
TestData.generate
ensure
TestData::DatabaseOptimizer.restore
end
end
desc 'Clean up existing test data'
task cleanup_test_data: :environment do
TestData.cleanup
end
end

18
lib/test_data.rb Normal file
View File

@ -0,0 +1,18 @@
module TestData
def self.generate
Orchestrator.call
end
def self.cleanup
CleanupService.call
end
end
require_relative 'test_data/constants'
require_relative 'test_data/database_optimizer'
require_relative 'test_data/cleanup_service'
require_relative 'test_data/account_creator'
require_relative 'test_data/inbox_creator'
require_relative 'test_data/display_id_tracker'
require_relative 'test_data/contact_batch_service'
require_relative 'test_data/orchestrator'

View File

@ -0,0 +1,31 @@
class TestData::AccountCreator
DATA_FILE = 'tmp/test_data_account_ids.txt'.freeze
def self.create!(id)
company_name = generate_company_name
domain = generate_domain(company_name)
account = Account.create!(
id: id,
name: company_name,
domain: domain,
created_at: Faker::Time.between(from: 2.years.ago, to: 6.months.ago)
)
persist_account_id(account.id)
account
end
def self.generate_company_name
"#{Faker::Company.name} #{TestData::Constants::COMPANY_TYPES.sample}"
end
def self.generate_domain(company_name)
"#{company_name.parameterize}.#{TestData::Constants::DOMAIN_EXTENSIONS.sample}"
end
def self.persist_account_id(account_id)
FileUtils.mkdir_p('tmp')
File.open(DATA_FILE, 'a') do |file|
file.write("#{account_id},")
end
end
end

View File

@ -0,0 +1,51 @@
class TestData::CleanupService
DATA_FILE = 'tmp/test_data_account_ids.txt'.freeze
class << self
def call
Rails.logger.info 'Cleaning up any existing test data...'
return log_no_file_found unless file_exists?
account_ids = parse_account_ids_from_file
if account_ids.any?
delete_accounts(account_ids)
else
log_no_accounts_found
end
delete_data_file
Rails.logger.info '==> Cleanup complete!'
end
private
def file_exists?
File.exist?(DATA_FILE)
end
def log_no_file_found
Rails.logger.info 'No test data file found, skipping cleanup'
end
def parse_account_ids_from_file
File.read(DATA_FILE).split(',').map(&:strip).reject(&:empty?).map(&:to_i)
end
def delete_accounts(account_ids)
Rails.logger.info "Found #{account_ids.size} test accounts to clean up: #{account_ids.join(', ')}"
start_time = Time.zone.now
Account.where(id: account_ids).destroy_all
Rails.logger.info "Deleted #{account_ids.size} accounts in #{Time.zone.now - start_time}s"
end
def log_no_accounts_found
Rails.logger.info 'No test account IDs found in the data file'
end
def delete_data_file
File.delete(DATA_FILE)
end
end
end

View File

@ -0,0 +1,18 @@
module TestData::Constants
NUM_ACCOUNTS = 20
MIN_MESSAGES = 1_000_000 # 1M
MAX_MESSAGES = 10_000_000 # 10M
BATCH_SIZE = 5_000
MAX_CONVERSATIONS_PER_CONTACT = 20
INBOXES_PER_ACCOUNT = 5
STATUSES = %w[open resolved pending].freeze
MESSAGE_TYPES = %w[incoming outgoing].freeze
MIN_MESSAGES_PER_CONVO = 5
MAX_MESSAGES_PER_CONVO = 50
COMPANY_TYPES = %w[Retail Healthcare Finance Education Manufacturing].freeze
DOMAIN_EXTENSIONS = %w[com io tech ai].freeze
COUNTRY_CODES = %w[1 44 91 61 81 86 49 33 34 39].freeze # US, UK, India, Australia, Japan, China, Germany, France, Spain, Italy
end

View File

@ -0,0 +1,196 @@
class TestData::ContactBatchService
def initialize(account:, inboxes:, batch_size:, display_id_tracker:)
@account = account
@inboxes = inboxes
@batch_size = batch_size
@display_id_tracker = display_id_tracker
@total_messages = 0
end
# Generates contacts, contact_inboxes, conversations, and messages
# Returns the total number of messages created in this batch
def generate!
Rails.logger.info { "Starting batch generation for account ##{@account.id} with #{@batch_size} contacts" }
create_contacts
create_contact_inboxes
create_conversations
create_messages
Rails.logger.info { "Completed batch with #{@total_messages} messages for account ##{@account.id}" }
@total_messages
end
private
# rubocop:disable Rails/SkipsModelValidations
def create_contacts
Rails.logger.info { "Creating #{@batch_size} contacts for account ##{@account.id}" }
start_time = Time.current
@contacts_data = Array.new(@batch_size) { build_contact_data }
Contact.insert_all!(@contacts_data) if @contacts_data.any?
@contacts = Contact
.where(account_id: @account.id)
.order(created_at: :desc)
.limit(@batch_size)
Rails.logger.info { "Contacts created in #{Time.current - start_time}s" }
end
# rubocop:enable Rails/SkipsModelValidations
def build_contact_data
created_at = Faker::Time.between(from: 1.year.ago, to: Time.current)
{
account_id: @account.id,
name: Faker::Name.name,
email: "#{SecureRandom.uuid}@example.com",
phone_number: generate_e164_phone_number,
additional_attributes: maybe_add_additional_attributes,
created_at: created_at,
updated_at: created_at
}
end
def maybe_add_additional_attributes
return unless rand < 0.3
{
company: Faker::Company.name,
city: Faker::Address.city,
country: Faker::Address.country_code
}
end
def generate_e164_phone_number
return nil unless rand < 0.7
country_code = TestData::Constants::COUNTRY_CODES.sample
subscriber_number = rand(1_000_000..9_999_999_999).to_s
subscriber_number = subscriber_number[0...(15 - country_code.length)]
"+#{country_code}#{subscriber_number}"
end
# rubocop:disable Rails/SkipsModelValidations
def create_contact_inboxes
Rails.logger.info { "Creating contact inboxes for #{@contacts.size} contacts" }
start_time = Time.current
contact_inboxes_data = @contacts.flat_map do |contact|
@inboxes.map do |inbox|
{
inbox_id: inbox.id,
contact_id: contact.id,
source_id: SecureRandom.uuid,
created_at: contact.created_at,
updated_at: contact.created_at
}
end
end
count = contact_inboxes_data.size
ContactInbox.insert_all!(contact_inboxes_data) if contact_inboxes_data.any?
@contact_inboxes = ContactInbox.where(contact_id: @contacts.pluck(:id))
Rails.logger.info { "Created #{count} contact inboxes in #{Time.current - start_time}s" }
end
# rubocop:enable Rails/SkipsModelValidations
# rubocop:disable Rails/SkipsModelValidations
def create_conversations
Rails.logger.info { "Creating conversations for account ##{@account.id}" }
start_time = Time.current
conversations_data = []
@contact_inboxes.each do |ci|
num_convos = rand(1..TestData::Constants::MAX_CONVERSATIONS_PER_CONTACT)
num_convos.times { conversations_data << build_conversation(ci) }
end
count = conversations_data.size
Rails.logger.info { "Preparing to insert #{count} conversations" }
Conversation.insert_all!(conversations_data) if conversations_data.any?
@conversations = Conversation.where(
account_id: @account.id,
display_id: conversations_data.pluck(:display_id)
).order(:created_at)
Rails.logger.info { "Created #{count} conversations in #{Time.current - start_time}s" }
end
# rubocop:enable Rails/SkipsModelValidations
def build_conversation(contact_inbox)
created_at = Faker::Time.between(from: contact_inbox.created_at, to: Time.current)
{
account_id: @account.id,
inbox_id: contact_inbox.inbox_id,
contact_id: contact_inbox.contact_id,
contact_inbox_id: contact_inbox.id,
status: TestData::Constants::STATUSES.sample,
created_at: created_at,
updated_at: created_at,
display_id: @display_id_tracker.next_id
}
end
# rubocop:disable Rails/SkipsModelValidations
def create_messages
Rails.logger.info { "Creating messages for #{@conversations.size} conversations" }
start_time = Time.current
batch_count = 0
@conversations.find_in_batches(batch_size: 1000) do |batch|
batch_count += 1
batch_start = Time.current
messages_data = batch.flat_map do |convo|
build_messages_for_conversation(convo)
end
batch_message_count = messages_data.size
Rails.logger.info { "Preparing to insert #{batch_message_count} messages (batch #{batch_count})" }
Message.insert_all!(messages_data) if messages_data.any?
@total_messages += batch_message_count
Rails.logger.info { "Created batch #{batch_count} with #{batch_message_count} messages in #{Time.current - batch_start}s" }
end
Rails.logger.info { "Created total of #{@total_messages} messages in #{Time.current - start_time}s" }
end
# rubocop:enable Rails/SkipsModelValidations
def build_messages_for_conversation(conversation)
num_messages = rand(TestData::Constants::MIN_MESSAGES_PER_CONVO..TestData::Constants::MAX_MESSAGES_PER_CONVO)
message_type = TestData::Constants::MESSAGE_TYPES.sample
time_range = [conversation.created_at, Time.current]
generate_messages(conversation, num_messages, message_type, time_range)
end
def generate_messages(conversation, num_messages, initial_message_type, time_range)
message_type = initial_message_type
Array.new(num_messages) do
message_type = (message_type == 'incoming' ? 'outgoing' : 'incoming')
created_at = Faker::Time.between(from: time_range.first, to: time_range.last)
build_message_data(conversation, message_type, created_at)
end
end
def build_message_data(conversation, message_type, created_at)
{
account_id: @account.id,
inbox_id: conversation.inbox_id,
conversation_id: conversation.id,
message_type: message_type,
content: Faker::Lorem.paragraph(sentence_count: 2),
created_at: created_at,
updated_at: created_at,
private: false,
status: 'sent',
content_type: 'text',
source_id: SecureRandom.uuid
}
end
end

View File

@ -0,0 +1,80 @@
class TestData::DatabaseOptimizer
class << self
# Tables that need trigger management
TABLES_WITH_TRIGGERS = %w[conversations messages].freeze
# Memory settings in MB
# Increased work_mem for better query performance with complex operations
WORK_MEM = 256
def setup
Rails.logger.info '==> Setting up database optimizations for improved performance'
# Remove statement timeout to allow long-running operations to complete
Rails.logger.info ' Removing statement timeout'
ActiveRecord::Base.connection.execute('SET statement_timeout = 0')
# Increase working memory for better query performance
Rails.logger.info " Increasing work_mem to #{WORK_MEM}MB"
ActiveRecord::Base.connection.execute("SET work_mem = '#{WORK_MEM}MB'")
# Set tables to UNLOGGED mode for better write performance
# This disables WAL completely for these tables
Rails.logger.info ' Setting tables to UNLOGGED mode'
set_tables_unlogged
# Disable triggers on specified tables to avoid overhead
Rails.logger.info ' Disabling triggers on specified tables'
disable_triggers
Rails.logger.info '==> Database optimizations complete, data generation will run faster'
end
def restore
Rails.logger.info '==> Restoring database settings to normal'
Rails.logger.info ' Re-enabling triggers on specified tables'
enable_triggers
Rails.logger.info ' Setting tables back to LOGGED mode'
set_tables_logged
# Reset memory settings to defaults
Rails.logger.info ' Resetting memory settings to defaults'
ActiveRecord::Base.connection.execute('RESET work_mem')
ActiveRecord::Base.connection.execute('RESET maintenance_work_mem')
Rails.logger.info '==> Database settings restored to normal operation'
end
private
def disable_triggers
TABLES_WITH_TRIGGERS.each do |table|
Rails.logger.info " Disabling triggers on #{table} table"
ActiveRecord::Base.connection.execute("ALTER TABLE #{table} DISABLE TRIGGER ALL")
end
end
def enable_triggers
TABLES_WITH_TRIGGERS.each do |table|
Rails.logger.info " Enabling triggers on #{table} table"
ActiveRecord::Base.connection.execute("ALTER TABLE #{table} ENABLE TRIGGER ALL")
end
end
def set_tables_unlogged
TABLES_WITH_TRIGGERS.each do |table|
Rails.logger.info " Setting #{table} table as UNLOGGED"
ActiveRecord::Base.connection.execute("ALTER TABLE #{table} SET UNLOGGED")
end
end
def set_tables_logged
TABLES_WITH_TRIGGERS.each do |table|
Rails.logger.info " Setting #{table} table as LOGGED"
ActiveRecord::Base.connection.execute("ALTER TABLE #{table} SET LOGGED")
end
end
end
end

View File

@ -0,0 +1,12 @@
class TestData::DisplayIdTracker
attr_reader :current
def initialize(account:)
max_display_id = Conversation.where(account_id: account.id).maximum(:display_id) || 0
@current = max_display_id
end
def next_id
@current += 1
end
end

View File

@ -0,0 +1,12 @@
class TestData::InboxCreator
def self.create_for(account)
Array.new(TestData::Constants::INBOXES_PER_ACCOUNT) do
channel = Channel::Api.create!(account: account)
Inbox.create!(
account_id: account.id,
name: "API Inbox #{SecureRandom.hex(4)}",
channel: channel
)
end
end
end

View File

@ -0,0 +1,109 @@
class TestData::Orchestrator
class << self
def call
Rails.logger.info { '========== STARTING TEST DATA GENERATION ==========' }
cleanup_existing_data
set_start_id
Rails.logger.info { "Starting to generate distributed test data across #{TestData::Constants::NUM_ACCOUNTS} accounts..." }
Rails.logger.info do
"Each account have between #{TestData::Constants::MIN_MESSAGES / 1_000_000}M and #{TestData::Constants::MAX_MESSAGES / 1_000_000}M messages"
end
TestData::Constants::NUM_ACCOUNTS.times do |account_index|
Rails.logger.info { "Processing account #{account_index + 1} of #{TestData::Constants::NUM_ACCOUNTS}" }
process_account(account_index)
end
Rails.logger.info { "========== ALL DONE! Created #{TestData::Constants::NUM_ACCOUNTS} accounts with distributed test data ==========" }
end
private
# Simple value object to group generation parameters
class DataGenerationParams
attr_reader :account, :inboxes, :total_contacts_needed, :target_message_count, :display_id_tracker
def initialize(account:, inboxes:, total_contacts_needed:, target_message_count:, display_id_tracker:)
@account = account
@inboxes = inboxes
@total_contacts_needed = total_contacts_needed
@target_message_count = target_message_count
@display_id_tracker = display_id_tracker
end
end
# 1. Remove existing data for old test accounts
def cleanup_existing_data
Rails.logger.info { 'Cleaning up existing test data...' }
TestData::CleanupService.call
Rails.logger.info { 'Cleanup complete' }
end
# 2. Find the max Account ID to avoid conflicts
def set_start_id
max_id = Account.maximum(:id) || 0
@start_id = max_id + 1
Rails.logger.info { "Setting start ID to #{@start_id}" }
end
# 3. Create an account, its inboxes, and some data
def process_account(account_index)
account_id = @start_id + account_index
Rails.logger.info { "Creating account with ID #{account_id}" }
account = TestData::AccountCreator.create!(account_id)
inboxes = TestData::InboxCreator.create_for(account)
target_messages = rand(TestData::Constants::MIN_MESSAGES..TestData::Constants::MAX_MESSAGES)
avg_per_convo = rand(15..50)
total_convos = (target_messages / avg_per_convo.to_f).ceil
total_contacts = (total_convos / TestData::Constants::MAX_CONVERSATIONS_PER_CONTACT.to_f).ceil
log_account_details(account, target_messages, total_contacts, total_convos)
display_id_tracker = TestData::DisplayIdTracker.new(account: account)
params = DataGenerationParams.new(
account: account,
inboxes: inboxes,
total_contacts_needed: total_contacts,
target_message_count: target_messages,
display_id_tracker: display_id_tracker
)
Rails.logger.info { "Starting data generation for account ##{account.id}" }
generate_data_for_account(params)
end
def generate_data_for_account(params)
contact_count = 0
message_count = 0
batch_number = 0
while contact_count < params.total_contacts_needed
batch_number += 1
batch_size = [TestData::Constants::BATCH_SIZE, params.total_contacts_needed - contact_count].min
Rails.logger.info { "Processing batch ##{batch_number} (#{batch_size} contacts) for account ##{params.account.id}" }
batch_service = TestData::ContactBatchService.new(
account: params.account,
inboxes: params.inboxes,
batch_size: batch_size,
display_id_tracker: params.display_id_tracker
)
batch_created_messages = batch_service.generate!
contact_count += batch_size
message_count += batch_created_messages
end
Rails.logger.info { "==> Completed Account ##{params.account.id} with #{message_count} messages" }
end
def log_account_details(account, target_messages, total_contacts, total_convos)
Rails.logger.info { "==> Account ##{account.id} plan: target of #{target_messages / 1_000_000.0}M messages" }
Rails.logger.info { " Planning for #{total_contacts} contacts and #{total_convos} conversations" }
end
end
end

View File

@ -9,7 +9,11 @@ RSpec.describe '/api/v1/widget/campaigns', type: :request do
describe 'GET /api/v1/widget/campaigns' do
let(:params) { { website_token: web_widget.website_token } }
context 'with correct website token' do
context 'when campaigns feature is enabled' do
before do
account.enable_features!('campaigns')
end
it 'returns the list of enabled campaigns' do
get '/api/v1/widget/campaigns', params: params
@ -21,8 +25,22 @@ RSpec.describe '/api/v1/widget/campaigns', type: :request do
end
end
context 'when campaigns feature is disabled' do
before do
account.disable_features!('campaigns')
end
it 'returns empty array' do
get '/api/v1/widget/campaigns', params: params
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).to eq []
end
end
context 'with invalid website token' do
it 'returns the list of agents' do
it 'returns not found status' do
get '/api/v1/widget/campaigns', params: { website_token: '' }
expect(response).to have_http_status(:not_found)
end