Compare commits

..
Author SHA1 Message Date
Tanmay Deep SharmaandGitHub d54f36aa19 Merge branch 'develop' into assignment_v2/leaves_controller 2025-08-20 20:49:51 +05:30
Sojan Jose 7aad3c9c6b Merge branch 'release/4.5.1' into develop 2025-08-20 16:25:33 +02:00
Sojan Jose c6113852d7 Bump version to 4.5.1 2025-08-20 16:23:38 +02:00
Sojan JoseandGitHub 94e930a141 fix(migrations): skip AddFeatureCitationToAssistantConfig on OSS (#12244) 2025-08-20 19:15:19 +05:30
Tanmay Deep Sharma d739e6e6e8 fix rubocop 2025-08-20 18:16:54 +05:30
Tanmay Deep Sharma 2d320bff17 remove extra document migration 2025-08-20 16:31:36 +05:30
Tanmay Deep Sharma d883d5d58e self review changes 2025-08-20 16:10:00 +05:30
Tanmay Deep Sharma 0fe9a3a5e0 self review changes 2025-08-20 14:49:25 +05:30
7fe94dc1a2 feat(voice): Call button on Contacts with inbox picker (#12218)
## Description
This introduces a reusable Call button used in the Contacts details
header (between Block contact and Send message) and
in the conversation contact panel. The button shows only when the
account has at least one Voice inbox and the contact
has a phone number. If multiple Voice inboxes are present, clicking
opens a picker modal; otherwise, a “Calling is under
development” toast is shown

> Actual wiring to functionality will available in follow up PRs

references: #11602 , #11481 

## Screens 

<img width="250" alt="Screenshot 2025-08-18 at 8 33 02 PM"
src="https://github.com/user-attachments/assets/d7a09a9d-8eff-4461-a75f-27854540c2a0"
/>
<img width="250" alt="Screenshot 2025-08-18 at 8 32 31 PM"
src="https://github.com/user-attachments/assets/682ae66e-dd5f-4750-9c30-0a210e120250"
/>
<img width="250" alt="Screenshot 2025-08-18 at 8 32 40 PM"
src="https://github.com/user-attachments/assets/7de0d753-eefc-4b7f-942b-ecca1964fcd7"
/>


## Test Cases

- Enable voice feature and create inboxes and test the cases for both
contact details view and contact card view in conversation
  - Details: 0 Voice inboxes → no Call button.
- Details: 1+ Voice inboxes, no phone number for contact → no Call
button.
- Details: 1 Voice inbox + phone number for contact → button visible;
click → toast.
- Details: >1 Voice inboxes + phone number for contact → click → modal →
choose → toast.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-08-19 15:42:01 +05:30
Chatwoot BotandGitHub cae097c5fa chore: Update translations (#12208) 2025-08-19 12:27:13 +05:30
faf35738b3 fix: Prevent reopening a resolved conversation (#11168)
# Pull Request Template

## Description

This PR addresses the issue where navigating back and starting a new
conversation incorrectly shows the previous messages or message screen.

Fixes
https://linear.app/chatwoot/issue/CW-4169/prevent-continue-conversation-in-previously-resolved-conversation

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Loom video
**Before**

https://www.loom.com/share/18172a3b26ff4e8faf8e1c3c1a0ba279?sid=ffbda52a-93e1-400f-bedc-17b925bae4d3

**After**

https://www.loom.com/share/584177d411424ad38c6812be868eb060?sid=fe5e771a-3faa-4c14-a5fe-918a3ccdb408

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-08-19 12:06:22 +05:30
341487b93e feat: Add assignment policies controllers with jbuilder views (#12199)
## Linear reference:
https://linear.app/chatwoot/issue/CW-4649/re-imagine-assignments

## Description
This PR introduces the foundation for Assignment V2 system by
implementing assignment policies and their association with inboxes.
Assignment policies allow configuring how conversations are distributed
among agents, with support for different assignment orders (round_robin
in community, balanced in enterprise) and conversation prioritization
strategies

Fixes # (issue)

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Test Coverage:
-  Controller specs for assignment policies CRUD operations
-  Enterprise-specific specs for balanced assignment order
-  Model specs for community/enterprise separation

Manual Testing:
1. Create assignment policy: POST
/api/v1/accounts/{id}/assignment_policies
2. List policies: GET /api/v1/accounts/{id}/assignment_policies
3. Assign policy to inbox: POST
/api/v1/accounts/{id}/assignment_policies/{id}/inboxes
4. View inbox policy: GET
/api/v1/accounts/{id}/inboxes/{id}/assignment_policy
5. Verify community edition ignores "balanced" assignment order
6. Verify enterprise edition supports both "round_robin" and "balanced"

- testing the flows after enterprise folder deletion

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-08-18 19:15:21 -07:00
Sojan Jose f569ed93ad Merge branch 'release/4.5.0' into develop 2025-08-18 18:40:19 +02:00
Sojan Jose eac292b11b Bump version to 4.5.0 2025-08-18 18:39:28 +02:00
Vishnu NarayananandGitHub 75119d4a08 fix: switch to datadog v2 gem (#12214)
# Pull Request Template

## Description
- The `0.48` version of the `ddtrace` gem was out of date, which was
causing the application to crash if `DD_AGENT_URL` was configured
- Switch to `datadog` gem, which is the currently maintained gem from DD


Ref: https://github.com/DataDog/dd-trace-rb/releases/tag/v2.0.0
2025-08-18 21:54:27 +05:30
Muhsin KelothandGitHub 67d7ee0185 feat: Add full change log to profile (#12215)
**Preview**
<img width="400" height="600" alt="CleanShot 2025-08-18 at 19 48 37@2x"
src="https://github.com/user-attachments/assets/28983793-adf4-4145-bb80-573e21cf5a0d"
/>
2025-08-18 21:40:13 +05:30
Tanmay Deep Sharma fe1c29a15e update the leave policy spec 2025-08-18 21:08:33 +05:30
Tanmay Deep Sharma 081a812243 fix rubocop for leave spec 2025-08-18 20:32:51 +05:30
Tanmay Deep Sharma 9031c710b7 self review changes 2025-08-18 19:26:08 +05:30
Tanmay Deep Sharma 5ce8533e9f update the classes from leave to leave record 2025-08-18 17:43:45 +05:30
Sojan JoseandGitHub 7e33e033cd docs: update AGENTS.md to include enterprise guidelines (#12213)
- Update agent.md to include enterprise guidelines
2025-08-18 16:50:34 +05:30
Tanmay Deep Sharma 857c8d90d2 fix rubocop for leave spec 2025-08-18 14:40:45 +05:30
Tanmay Deep Sharma d5d13792a8 add leave controllers and rspec 2025-08-18 14:22:27 +05:30
Sivin VargheseandGitHub 02fa76f3df chore: Update contact empty state data (#12207)
# Pull Request Template

## Description

Fixes
https://linear.app/chatwoot/issue/CW-5432/use-a-different-company-nameemail-for-the-empty-state-in-contacts


## How Has This Been Tested?

### Screenshot
<img width="1044" height="555" alt="image"
src="https://github.com/user-attachments/assets/a414f88f-13ca-4c1e-bb76-cd5b9217d21f"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-08-18 12:06:03 +05:30
91d80004a6 chore: Improvements in scenarios (#12098)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-14 19:07:28 +05:30
b809cd2f15 chore: Optimize contact page for smaller displays (#12183)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-08-14 19:07:20 +05:30
100 changed files with 2052 additions and 2160 deletions
+18 -1
View File
@@ -55,4 +55,21 @@
## Ruby Best Practices
- Use compact `module/class` definitions; avoid nested styles
- Use compact `module/class` definitions; avoid nested styles
## Enterprise Edition Notes
- Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code.
- When you add or modify core functionality, always check for corresponding files in `enterprise/` and keep behavior compatible.
- Follow the Enterprise development practices documented here:
- https://chatwoot.help/hc/handbook/articles/developing-enterprise-edition-features-38
Practical checklist for any change impacting core logic or public APIs
- Search for related files in both trees before editing (e.g., `rg -n "FooService|ControllerName|ModelName" app enterprise`).
- If adding new endpoints, services, or models, consider whether Enterprise needs:
- An override (e.g., `enterprise/app/...`), or
- An extension point (e.g., `prepend_mod_with`, hooks, configuration) to avoid hard forks.
- Avoid hardcoding instance- or plan-specific behavior in OSS; prefer configuration, feature flags, or extension points consumed by Enterprise.
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
+1 -1
View File
@@ -108,7 +108,7 @@ gem 'google-cloud-translate-v3', '>= 0.7.0'
##-- apm and error monitoring ---#
# loaded only when environment variables are set.
# ref application.rb
gem 'ddtrace', require: false
gem 'datadog', '~> 2.0', require: false
gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
+18 -4
View File
@@ -194,10 +194,14 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.4.1)
ddtrace (0.48.0)
ffi (~> 1.0)
datadog (2.19.0)
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
libdatadog (~> 18.1.0.1.0)
libddwaf (~> 1.24.1.0.3)
logger
msgpack
datadog-ruby_core_source (3.4.1)
date (3.4.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -444,6 +448,16 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
libdatadog (18.1.0.1.0)
libdatadog (18.1.0.1.0-x86_64-linux)
libddwaf (1.24.1.0.3)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0)
@@ -928,7 +942,7 @@ DEPENDENCIES
commonmarker
csv-safe
database_cleaner
ddtrace
datadog (~> 2.0)
debug (~> 1.8)
devise (>= 4.9.4)
devise-secure_password!
@@ -0,0 +1,20 @@
class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_assignment_policy
before_action -> { check_authorization(AssignmentPolicy) }
def index
@inboxes = @assignment_policy.inboxes
end
private
def fetch_assignment_policy
@assignment_policy = Current.account.assignment_policies.find(
params[:assignment_policy_id]
)
end
def permitted_params
params.permit(:assignment_policy_id)
end
end
@@ -1,42 +1,24 @@
# frozen_string_literal: true
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
before_action :check_authorization
def index
@assignment_policies = Current.account.assignment_policies.includes(:inboxes)
render json: { assignment_policies: serialize_assignment_policies(@assignment_policies) }
@assignment_policies = Current.account.assignment_policies
end
def show
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
end
def show; end
def create
@assignment_policy = Current.account.assignment_policies.build(assignment_policy_params)
if @assignment_policy.save
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }, status: :created
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
@assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params)
end
def update
if @assignment_policy.update(assignment_policy_params)
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
@assignment_policy.update!(assignment_policy_params)
end
def destroy
if @assignment_policy.destroy
head :ok
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
@assignment_policy.destroy!
head :ok
end
private
@@ -51,29 +33,4 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
:fair_distribution_limit, :fair_distribution_window, :enabled
)
end
def serialize_assignment_policy(policy)
{
id: policy.id,
name: policy.name,
description: policy.description,
assignment_order: policy.assignment_order,
conversation_priority: policy.conversation_priority,
fair_distribution_limit: policy.fair_distribution_limit,
fair_distribution_window: policy.fair_distribution_window,
enabled: policy.enabled,
inbox_count: policy.inboxes.count,
inboxes: policy.inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
created_at: policy.created_at,
updated_at: policy.updated_at
}
end
def serialize_assignment_policies(policies)
policies.map { |policy| serialize_assignment_policy(policy) }
end
def check_authorization
authorize(AssignmentPolicy)
end
end
@@ -1,81 +0,0 @@
# frozen_string_literal: true
class Api::V1::Accounts::InboxAssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :check_authorization
def show
@inbox_assignment_policy = @inbox.inbox_assignment_policy
if @inbox_assignment_policy
render json: {
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
}
else
render json: {
inbox_assignment_policy: nil,
message: 'No assignment policy assigned to this inbox'
}
end
end
def create
# Remove existing assignment if any
@inbox.inbox_assignment_policy&.destroy
@assignment_policy = Current.account.assignment_policies.find(params[:assignment_policy_id])
@inbox_assignment_policy = @inbox.build_inbox_assignment_policy(assignment_policy: @assignment_policy)
if @inbox_assignment_policy.save
render json: {
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
}, status: :created
else
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
@inbox_assignment_policy = @inbox.inbox_assignment_policy
if @inbox_assignment_policy
if @inbox_assignment_policy.destroy
head :ok
else
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
else
render json: { error: 'No assignment policy found for this inbox' }, status: :not_found
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def check_authorization
authorize(@inbox, :update?)
end
def serialize_inbox_assignment_policy(inbox_assignment_policy)
{
id: inbox_assignment_policy.id,
inbox_id: inbox_assignment_policy.inbox_id,
assignment_policy_id: inbox_assignment_policy.assignment_policy_id,
assignment_policy: {
id: inbox_assignment_policy.assignment_policy.id,
name: inbox_assignment_policy.assignment_policy.name,
description: inbox_assignment_policy.assignment_policy.description,
assignment_order: inbox_assignment_policy.assignment_policy.assignment_order,
conversation_priority: inbox_assignment_policy.assignment_policy.conversation_priority,
fair_distribution_limit: inbox_assignment_policy.assignment_policy.fair_distribution_limit,
fair_distribution_window: inbox_assignment_policy.assignment_policy.fair_distribution_window,
enabled: inbox_assignment_policy.assignment_policy.enabled
},
created_at: inbox_assignment_policy.created_at,
updated_at: inbox_assignment_policy.updated_at
}
end
end
@@ -0,0 +1,46 @@
class Api::V1::Accounts::Inboxes::AssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_assignment_policy, only: [:create]
before_action -> { check_authorization(AssignmentPolicy) }
before_action :validate_assignment_policy, only: [:show, :destroy]
def show
@assignment_policy = @inbox.assignment_policy
end
def create
# There should be only one assignment policy for an inbox.
# If there is a new request to add an assignment policy, we will
# delete the old one and attach the new policy
remove_inbox_assignment_policy
@inbox_assignment_policy = @inbox.create_inbox_assignment_policy!(assignment_policy: @assignment_policy)
@assignment_policy = @inbox.assignment_policy
end
def destroy
remove_inbox_assignment_policy
head :ok
end
private
def remove_inbox_assignment_policy
@inbox.inbox_assignment_policy&.destroy
end
def fetch_inbox
@inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
end
def fetch_assignment_policy
@assignment_policy = Current.account.assignment_policies.find(permitted_params[:assignment_policy_id])
end
def permitted_params
params.permit(:assignment_policy_id, :inbox_id)
end
def validate_assignment_policy
return render_not_found_error(I18n.t('errors.assignment_policy.not_found')) unless @inbox.assignment_policy
end
end
@@ -1,11 +1,13 @@
<script setup>
import { computed, useSlots } from 'vue';
import { computed, useSlots, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
const props = defineProps({
selectedContact: {
@@ -24,6 +26,8 @@ const { t } = useI18n();
const slots = useSlots();
const route = useRoute();
const isContactSidebarOpen = ref(false);
const contactId = computed(() => route.params.contactId);
const selectedContactName = computed(() => {
@@ -56,6 +60,15 @@ const handleBreadcrumbClick = () => {
const toggleBlock = () => {
emit('toggleBlock', isContactBlocked.value);
};
const handleConversationSidebarToggle = () => {
isContactSidebarOpen.value = !isContactSidebarOpen.value;
};
const closeMobileSidebar = () => {
if (!isContactSidebarOpen.value) return;
isContactSidebarOpen.value = false;
};
</script>
<template>
@@ -67,7 +80,9 @@ const toggleBlock = () => {
>
<header class="sticky top-0 z-10 px-6 3xl:px-0">
<div class="w-full mx-auto max-w-[40.625rem]">
<div class="flex items-center justify-between w-full h-20 gap-2">
<div
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
>
<Breadcrumb
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
@@ -85,6 +100,11 @@ const toggleBlock = () => {
:disabled="isUpdating"
@click="toggleBlock"
/>
<VoiceCallButton
:phone="selectedContact?.phoneNumber"
:label="$t('CONTACT_PANEL.CALL')"
size="sm"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button
@@ -105,11 +125,65 @@ const toggleBlock = () => {
</main>
</div>
<!-- Desktop sidebar -->
<div
v-if="slots.sidebar"
class="overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
</div>
<!-- Mobile sidebar container -->
<div
v-if="slots.sidebar"
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
:class="isContactSidebarOpen ? 'w-full' : 'w-16'"
>
<!-- Toggle button -->
<div
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#contact-sidebar-content'] },
]"
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
:class="[
isContactSidebarOpen
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
]"
>
<Button
ghost
slate
sm
class="!rounded-full rtl:rotate-180"
:class="{ 'bg-n-alpha-2': isContactSidebarOpen }"
:icon="
isContactSidebarOpen
? 'i-lucide-panel-right-close'
: 'i-lucide-panel-right-open'
"
data-contact-sidebar-toggle
@click="handleConversationSidebarToggle"
/>
</div>
<Transition
enter-active-class="transition-transform duration-200 ease-in-out"
leave-active-class="transition-transform duration-200 ease-in-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
>
<div
v-if="isContactSidebarOpen"
id="contact-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
>
<slot name="sidebar" />
</div>
</Transition>
</div>
</section>
</template>
@@ -34,13 +34,13 @@ const emit = defineEmits([
<template>
<header class="sticky top-0 z-10">
<div
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[60rem]"
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2">
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2 w-full">
<Input
:model-value="searchValue"
type="search"
@@ -48,6 +48,7 @@ const emit = defineEmits([
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
class="w-full"
@input="emit('search', $event.target.value)"
>
<template #prefix>
@@ -58,64 +59,66 @@ const emit = defineEmits([
</template>
</Input>
</div>
<div class="flex items-center gap-2">
<div v-if="!isLabelView && !isActiveView" class="relative">
<div class="flex items-center flex-shrink-0 gap-4">
<div class="flex items-center gap-2">
<div v-if="!isLabelView && !isActiveView" class="relative">
<Button
id="toggleContactsFilterButton"
:icon="
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
"
color="slate"
size="sm"
class="relative w-8"
variant="ghost"
@click="emit('filter')"
>
<div
v-if="hasActiveFilters && !isSegmentsView"
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
/>
</Button>
<slot name="filter" />
</div>
<Button
id="toggleContactsFilterButton"
:icon="
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
v-if="
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
icon="i-lucide-save"
color="slate"
size="sm"
class="relative w-8"
variant="ghost"
@click="emit('filter')"
>
<div
v-if="hasActiveFilters && !isSegmentsView"
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
/>
</Button>
<slot name="filter" />
@click="emit('createSegment')"
/>
<Button
v-if="isSegmentsView && !isLabelView && !isActiveView"
icon="i-lucide-trash"
color="slate"
size="sm"
variant="ghost"
@click="emit('deleteSegment')"
/>
<ContactSortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<ContactMoreActions
@add="emit('add')"
@import="emit('import')"
@export="emit('export')"
/>
</div>
<Button
v-if="
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
icon="i-lucide-save"
color="slate"
size="sm"
variant="ghost"
@click="emit('createSegment')"
/>
<Button
v-if="isSegmentsView && !isLabelView && !isActiveView"
icon="i-lucide-trash"
color="slate"
size="sm"
variant="ghost"
@click="emit('deleteSegment')"
/>
<ContactSortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<ContactMoreActions
@add="emit('add')"
@import="emit('import')"
@export="emit('export')"
/>
<div class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
<div class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</header>
@@ -291,17 +291,20 @@ defineExpose({
@delete-segment="openDeleteSegmentDialog"
>
<template #filter>
<ContactsFilter
v-if="showFiltersModal"
v-model="appliedFilter"
:segment-name="activeSegmentName"
:is-segment-view="hasActiveSegments"
class="absolute mt-1 ltr:right-0 rtl:left-0 top-full"
@apply-filter="onApplyFilter"
@update-segment="onUpdateSegment"
@close="closeAdvanceFiltersModal"
@clear-filters="clearFilters"
/>
<div
class="absolute mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
>
<ContactsFilter
v-if="showFiltersModal"
v-model="appliedFilter"
:segment-name="activeSegmentName"
:is-segment-view="hasActiveSegments"
@apply-filter="onApplyFilter"
@update-segment="onUpdateSegment"
@close="closeAdvanceFiltersModal"
@clear-filters="clearFilters"
/>
</div>
</template>
</ContactsHeader>
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 ltr:right-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -96,10 +96,7 @@ const openFilter = () => {
<slot name="default" />
</div>
</main>
<footer
v-if="showPaginationFooter"
class="sticky bottom-0 z-10 px-4 pb-4"
>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
@@ -4,8 +4,8 @@ export default [
city: 'Los Angeles',
country: 'United States',
description:
"I'm Candice, a developer focusing on building web solutions. Currently, Im working as a Product Developer at Chatwoot.",
companyName: 'Chatwoot',
"I'm Candice, a developer focusing on building web solutions. Currently, Im working as a Product Developer at Lumora.",
companyName: 'Lumora',
countryCode: 'US',
socialProfiles: {
github: 'candice-dev',
@@ -16,7 +16,7 @@ export default [
},
},
availabilityStatus: 'offline',
email: 'candice.matherson@chatwoot.com',
email: 'candice.matherson@lumora.com',
id: 22,
name: 'Candice Matherson',
phoneNumber: '+14155552671',
@@ -0,0 +1,91 @@
<script setup>
import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
phone: { type: String, default: '' },
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
size: { type: String, default: 'sm' },
tooltipLabel: { type: String, default: '' },
});
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const { t } = useI18n();
const inboxesList = useMapGetter('inboxes/getInboxes');
const voiceInboxes = computed(() =>
(inboxesList.value || []).filter(
inbox => inbox.channel_type === INBOX_TYPES.VOICE
)
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const dialogRef = ref(null);
const onClick = () => {
if (voiceInboxes.value.length > 1) {
dialogRef.value?.open();
return;
}
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
};
const onPickInbox = () => {
// Placeholder until actual call wiring happens
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
dialogRef.value?.close();
};
</script>
<template>
<span class="contents">
<Button
v-if="shouldRender"
v-tooltip.top-end="tooltipLabel || null"
v-bind="attrs"
:label="label"
:icon="icon"
:size="size"
@click="onClick"
/>
<Dialog
v-if="shouldRender && voiceInboxes.length > 1"
ref="dialogRef"
:title="$t('CONTACT_PANEL.VOICE_INBOX_PICKER.TITLE')"
show-cancel-button
:show-confirm-button="false"
width="md"
>
<div class="flex flex-col gap-2">
<button
v-for="inbox in voiceInboxes"
:key="inbox.id"
type="button"
class="flex items-center justify-between w-full px-4 py-2 text-left rounded-lg hover:bg-n-alpha-2"
@click="onPickInbox(inbox)"
>
<div class="flex items-center gap-2">
<span class="i-ri-phone-fill text-n-slate-10" />
<span class="text-sm text-n-slate-12">{{ inbox.name }}</span>
</div>
<span v-if="inbox.phone_number" class="text-xs text-n-slate-10">
{{ inbox.phone_number }}
</span>
</button>
</div>
</Dialog>
</span>
</template>
@@ -47,6 +47,7 @@ const unreadMessagesCount = computed(() => {
</p>
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
<Avatar
v-if="assignee.name"
:name="assignee.name"
:src="assignee.thumbnail"
:size="20"
@@ -96,6 +96,7 @@ defineExpose({
/>
</div>
<Avatar
v-if="assignee.name"
:name="assignee.name"
:src="assignee.thumbnail"
:size="20"
@@ -21,9 +21,17 @@ const onClick = (item, index) => {
</script>
<template>
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
<ol class="flex items-center mb-0">
<li v-for="(item, index) in items" :key="index" class="flex items-center">
<nav
:aria-label="t('BREADCRUMB.ARIA_LABEL')"
class="flex items-center h-8 min-w-0"
>
<ol class="flex items-center mb-0 min-w-0">
<li
v-for="(item, index) in items"
:key="index"
class="flex items-center"
:class="{ 'min-w-0 flex-1': index === items.length - 1 }"
>
<Icon
v-if="index > 0"
icon="i-lucide-chevron-right"
@@ -40,7 +48,7 @@ const onClick = (item, index) => {
</button>
<!-- The last breadcrumb item is plain text -->
<span v-else class="text-sm truncate text-n-slate-12 max-w-56">
<span v-else class="text-sm truncate text-n-slate-12 min-w-0 block">
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</li>
@@ -52,10 +52,10 @@ const handleBreadcrumbClick = item => {
<template>
<section
class="mt-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
class="px-6 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
>
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
<header class="mb-7 sticky top-0 z-10 bg-n-background">
<header class="mb-7 sticky top-0 bg-n-background pt-4 z-20">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</header>
<main class="flex gap-16 w-full flex-1 pb-16">
@@ -1,7 +1,7 @@
<script setup>
import { computed, h, reactive } from 'vue';
import { computed, h, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useToggle, useElementSize } from '@vueuse/core';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
@@ -11,6 +11,7 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
id: {
@@ -31,7 +32,7 @@ const props = defineProps({
},
tools: {
type: Array,
required: true,
default: () => [],
},
selectable: {
type: Boolean,
@@ -60,7 +61,13 @@ const state = reactive({
instruction: '',
});
const instructionContentRef = ref();
const [isEditing, toggleEditing] = useToggle();
const [isInstructionExpanded, toggleInstructionExpanded] = useToggle();
const { height: contentHeight } = useElementSize(instructionContentRef);
const needsOverlay = computed(() => contentHeight.value > 160);
const startEdit = () => {
Object.assign(state, {
@@ -111,7 +118,7 @@ const LINK_INSTRUCTION_CLASS =
const renderInstruction = instruction => () =>
h('p', {
class: `text-sm text-n-slate-12 py-4 mb-0 [&_ol]:list-decimal ${LINK_INSTRUCTION_CLASS}`,
class: `text-sm text-n-slate-12 py-4 mb-0 prose prose-sm min-w-0 break-words max-w-none ${LINK_INSTRUCTION_CLASS}`,
innerHTML: instruction,
});
</script>
@@ -157,8 +164,38 @@ const renderInstruction = instruction => () =>
/>
</div>
</div>
<component :is="renderInstruction(formatMessage(instruction, false))" />
<span class="text-sm text-n-slate-11 font-medium mb-1">
<div
class="relative overflow-hidden transition-all duration-300 ease-in-out group/expandable"
:class="{ 'cursor-pointer': needsOverlay }"
:style="{
maxHeight: isInstructionExpanded ? `${contentHeight}px` : '10rem',
}"
@click="needsOverlay ? toggleInstructionExpanded() : null"
>
<div ref="instructionContentRef">
<component
:is="renderInstruction(formatMessage(instruction, false))"
/>
</div>
<div
class="absolute bottom-0 w-full flex items-end justify-center text-xs text-n-slate-11 bg-gradient-to-t h-40 from-n-solid-2 via-n-solid-2 via-10% to-transparent transition-all duration-500 ease-in-out px-2 py-1 rounded pointer-events-none"
:class="{
'visible opacity-100': !isInstructionExpanded,
'invisible opacity-0': isInstructionExpanded || !needsOverlay,
}"
>
<Icon
icon="i-lucide-chevron-down"
class="text-n-slate-7 mb-4 size-4 group-hover/expandable:text-n-slate-11 transition-colors duration-200"
/>
</div>
</div>
<span
v-if="tools?.length"
class="text-sm text-n-slate-11 font-medium mb-1"
>
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
{{ tools?.map(tool => `@${tool}`).join(', ') }}
</span>
@@ -192,6 +192,7 @@ defineExpose({ validate });
solid
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click.stop="emit('remove')"
/>
</div>
@@ -104,7 +104,7 @@ const outsideClickHandler = [
<template>
<div
v-on-click-outside="outsideClickHandler"
class="z-40 max-w-3xl lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
class="z-40 max-w-3xl min-w-96 lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
>
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ filterModalHeaderTitle }}
@@ -146,10 +146,10 @@ const outsideClickHandler = [
</template>
</ul>
<div class="flex justify-between gap-2">
<Button sm ghost blue @click="addFilter">
<Button sm ghost blue class="flex-shrink-0" @click="addFilter">
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.ADD_FILTER') }}
</Button>
<div class="flex gap-2">
<div class="flex gap-2 flex-shrink-0">
<Button sm faded slate @click="resetFilter">
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.CLEAR_FILTERS') }}
</Button>
@@ -86,6 +86,15 @@ const menuItems = computed(() => {
nativeLink: true,
target: '_blank',
},
{
show: true,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.CHANGELOG'),
icon: 'i-lucide-scroll-text',
link: 'https://www.chatwoot.com/changelog/',
nativeLink: true,
target: '_blank',
},
{
show: currentUser.value.type === 'SuperAdmin',
showOnCustomBrandedInstance: true,
@@ -114,7 +123,7 @@ const allowedMenuItems = computed(() => {
<DropdownContainer class="relative w-full min-w-0" @close="emit('close')">
<template #trigger="{ toggle, isOpen }">
<button
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
class="flex gap-2 items-center p-1 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
:class="{ 'bg-n-alpha-1': isOpen }"
@click="toggle"
>
@@ -127,16 +136,16 @@ const allowedMenuItems = computed(() => {
rounded-full
/>
<div class="min-w-0">
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
{{ currentUser.available_name }}
</div>
<div class="text-n-slate-11 text-xs truncate">
<div class="text-xs truncate text-n-slate-11">
{{ currentUser.email }}
</div>
</div>
</button>
</template>
<DropdownBody class="ltr:left-0 rtl:right-0 bottom-12 z-50 w-80 mb-2">
<DropdownBody class="bottom-12 z-50 mb-2 w-80 ltr:left-0 rtl:right-0">
<SidebarProfileMenuStatus />
<DropdownSeparator />
<template v-for="item in allowedMenuItems" :key="item.label">
@@ -17,6 +17,11 @@
"IP_ADDRESS": "IP Address",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "New message",
"CALL": "Call",
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
},
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
"TITLE": "Previous Conversations"
@@ -226,6 +226,7 @@
"APPEARANCE": "Change appearance",
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
"DOCS": "Read documentation",
"CHANGELOG": "Changelog",
"LOGOUT": "Log out"
},
"APP_GLOBAL": {
@@ -13,7 +13,7 @@
"TEXT": "Texto",
"NUMBER": "Número",
"LINK": "Link",
"DATE": "Date",
"DATE": "Data",
"LIST": "Lista",
"CHECKBOX": "Checkbox"
},
@@ -131,7 +131,7 @@
"CONVERSATION_CREATED": "Conversa Criada",
"CONVERSATION_UPDATED": "Conversa Atualizada",
"MESSAGE_CREATED": "Mensagem Criada",
"CONVERSATION_RESOLVED": "Conversation Resolved",
"CONVERSATION_RESOLVED": "Conversa resolvida",
"CONVERSATION_OPENED": "Conversa Aberta"
},
"ACTIONS": {
@@ -153,8 +153,8 @@
"OPEN_CONVERSATION": "Abrir conversa"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
"INCOMING": "Mensagem Recebida",
"OUTGOING": "Mensagem de Saída"
},
"PRIORITY_TYPES": {
"NONE": "Nenhuma",
@@ -138,11 +138,11 @@
}
},
"WHATSAPP": {
"HEADER_TITLE": "WhatsApp campaigns",
"HEADER_TITLE": "Campanhas do WhatsApp",
"NEW_CAMPAIGN": "Criar campanha",
"EMPTY_STATE": {
"TITLE": "No WhatsApp campaigns are available",
"SUBTITLE": "Launch a WhatsApp campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started."
"TITLE": "Nenhuma campanha do WhatsApp está disponível",
"SUBTITLE": "Inicie uma campanha do WhatsApp para atingir seus clientes diretamente. Envie ofertas ou faça anúncios facilmente. Clique em \"Criar campanha\" para começar."
},
"CARD": {
"STATUS": {
@@ -155,7 +155,7 @@
}
},
"CREATE": {
"TITLE": "Create WhatsApp campaign",
"TITLE": "Criar campanha do WhatsApp",
"CANCEL_BUTTON_TEXT": "Cancelar",
"CREATE_BUTTON_TEXT": "Criar",
"FORM": {
@@ -170,15 +170,15 @@
"ERROR": "Caixa de entrada obrigatória"
},
"TEMPLATE": {
"LABEL": "WhatsApp Template",
"PLACEHOLDER": "Select a template",
"INFO": "Select a template to use for this campaign.",
"ERROR": "Template is required",
"LABEL": "Modelo do WhatsApp",
"PLACEHOLDER": "Selecione um modelo",
"INFO": "Selecione um modelo para usar para esta campanha.",
"ERROR": "Modelo é obrigatório",
"PREVIEW_TITLE": "Processar {templateName}",
"LANGUAGE": "Idioma",
"CATEGORY": "Categoria",
"VARIABLES_LABEL": "Variáveis",
"VARIABLE_PLACEHOLDER": "Enter value for {variable}"
"VARIABLE_PLACEHOLDER": "Digite um valor para {variable}"
},
"AUDIENCE": {
"LABEL": "Público",
@@ -195,7 +195,7 @@
"CANCEL": "Cancelar"
},
"API": {
"SUCCESS_MESSAGE": "WhatsApp campaign created successfully",
"SUCCESS_MESSAGE": "Campanha do WhatsApp criada com sucesso",
"ERROR_MESSAGE": "Houve um erro. Por favor, tente novamente."
}
}
@@ -51,6 +51,6 @@
"PLACEHOLDER": "Insira a duração"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
"COMING_SOON": "Em breve!"
}
}
@@ -144,9 +144,9 @@
"AGENTS_LOADING": "Carregando agentes...",
"ASSIGN_TEAM": "Atribuir time",
"DELETE": "Excluir conversa",
"OPEN_IN_NEW_TAB": "Open in new tab",
"COPY_LINK": "Copy conversation link",
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
"OPEN_IN_NEW_TAB": "Abrir em nova aba",
"COPY_LINK": "Copiar link da conversa",
"COPY_LINK_SUCCESS": "Link da conversa copiado",
"API": {
"AGENT_ASSIGNMENT": {
"SUCCESFUL": "ID da conversa {conversationId} atribuído para \"{agentName}\"",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "Você excedeu o limite de conversas. O plano Hacker permite apenas 500 conversas.",
"INBOXES": "Você excedeu o limite da caixa de entrada. O plano Hacker só suporta chat ao vivo do site. Caixas adicionais como e-mail, WhatsApp etc. requerem um plano pago.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"AGENTS": "Você excedeu o limite do agente. Seu plano permite apenas {allowedAgents} agentes.",
"NON_ADMIN": "Entre em contato com o administrador para atualizar o plano e continuar usando todos os recursos."
},
"TITLE": "Conta",
@@ -134,7 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Digite enter para selecionar",
"ENTER_TO_REMOVE": "Digite enter para remover",
"NO_OPTIONS": "List is empty",
"NO_OPTIONS": "Lista vazia",
"SELECT_ONE": "Selecione um",
"SELECT": "Selecionar"
}
@@ -160,8 +160,8 @@
},
"SEND_CNAME_INSTRUCTIONS": {
"API": {
"SUCCESS_MESSAGE": "CNAME instructions sent successfully",
"ERROR_MESSAGE": "Error while sending CNAME instructions"
"SUCCESS_MESSAGE": "Instruções do CNAME enviadas com sucesso",
"ERROR_MESSAGE": "Erro ao enviar as instruções CNAME"
}
}
},
@@ -732,7 +732,7 @@
"HOME_PAGE_LINK": {
"LABEL": "Link da Página Inicial",
"PLACEHOLDER": "Link da página inicial do portal",
"ERROR": "Enter a valid URL. The Home page link must start with 'http://' or 'https://'."
"ERROR": "Digite uma URL válida. O link da página inicial deve começar com 'http://' ou 'https://'."
},
"SLUG": {
"LABEL": "Slug",
@@ -753,14 +753,14 @@
"HEADER": "Domínio personalizado",
"LABEL": "Domínio personalizado:",
"DESCRIPTION": "Você pode hospedar seu portal em um domínio personalizado. Por exemplo, se seu site for meudominio.com e você quer o seu portal disponível em docs.meudominio.com, basta digitar isso neste campo.",
"STATUS_DESCRIPTION": "Your custom portal will start working as soon as it is verified.",
"STATUS_DESCRIPTION": "Seu portal personalizado começará a funcionar assim que for verificado.",
"PLACEHOLDER": "Domínio personalizado do portal",
"EDIT_BUTTON": "Alterar",
"ADD_BUTTON": "Adicionar domínio personalizado",
"STATUS": {
"LIVE": "Em tempo real",
"PENDING": "Awaiting verification",
"ERROR": "Verification failed"
"PENDING": "Aguardando verificação",
"ERROR": "Verificação falhou"
},
"DIALOG": {
"ADD_HEADER": "Adicionar domínio personalizado",
@@ -770,17 +770,17 @@
"LABEL": "Domínio personalizado",
"PLACEHOLDER": "Domínio personalizado do portal",
"ERROR": "Domínio personalizado é obrigatório",
"FORMAT_ERROR": "Please enter a valid domain URL e.g. docs.yourdomain.com"
"FORMAT_ERROR": "Por favor, insira um domínio de URL válido, ex.: docs.seudominio.com"
},
"DNS_CONFIGURATION_DIALOG": {
"HEADER": "Configuração de DNS",
"DESCRIPTION": "Faça o login na conta que você tem com seu provedor DNS e adicione um registro CNAME para subdomínio apontando para chatwoot.help",
"COPY": "Successfully copied CNAME",
"COPY": "CNAME copiado com sucesso",
"SEND_INSTRUCTIONS": {
"HEADER": "Send instructions",
"DESCRIPTION": "If you would prefer to have someone from your development team to handle this step, you can enter email address below, and we will send them the required instructions.",
"PLACEHOLDER": "Enter their email",
"ERROR": "Enter a valid email address",
"HEADER": "Enviar instruções",
"DESCRIPTION": "Se você preferir ter alguém da sua equipe de desenvolvimento para lidar com essa etapa, você pode digitar o endereço de e-mail abaixo e nós enviaremos as instruções necessárias.",
"PLACEHOLDER": "Insira o e-mail dele",
"ERROR": "Insira um endereço de e-mail válido",
"SEND_BUTTON": "Enviar"
}
}
@@ -74,21 +74,21 @@
"DELETE_ALL_READ": "Todas as notificações lidas foram excluídas"
},
"REAUTHORIZE": {
"TITLE": "Reauthorization Required",
"DESCRIPTION": "Your WhatsApp connection has expired. Please reconnect to continue receiving and sending messages.",
"BUTTON_TEXT": "Reconnect WhatsApp",
"LOADING_FACEBOOK": "Loading Facebook SDK...",
"SUCCESS": "WhatsApp reconnected successfully",
"ERROR": "Failed to reconnect WhatsApp. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"CONFIGURATION_ERROR": "Configuration error occurred during reauthorization.",
"FACEBOOK_LOAD_ERROR": "Failed to load Facebook SDK. Please try again.",
"TITLE": "Reautenticação necessária",
"DESCRIPTION": "Sua conexão com o WhatsApp expirou. Por favor, reconecte para continuar recebendo e enviando mensagens.",
"BUTTON_TEXT": "Reconectar WhatsApp",
"LOADING_FACEBOOK": "Carregando SDK do Facebook...",
"SUCCESS": "WhatsApp reconectado com sucesso",
"ERROR": "Falha ao reconectar o WhatsApp. Por favor, tente novamente.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID não está configurado. Por favor, contate seu administrador.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID não está configurado. Por favor, contate seu administrador.",
"CONFIGURATION_ERROR": "Ocorreu um erro de configuração ao reautenticar.",
"FACEBOOK_LOAD_ERROR": "Falha para carregar o SDK do Facebook. Por favor, tente novamente.",
"TROUBLESHOOTING": {
"TITLE": "Troubleshooting",
"POPUP_BLOCKED": "Ensure pop-ups are allowed for this site",
"COOKIES": "Third-party cookies must be enabled",
"ADMIN_ACCESS": "You need admin access to the WhatsApp Business Account"
"TITLE": "Solucionar problemas",
"POPUP_BLOCKED": "Certifique-se de que os pop-ups são permitidos para este site",
"COOKIES": "_Cookies_ de terceiros devem estar habilitados",
"ADMIN_ACCESS": "Você precisa de acesso de administrador na conta do WhatsApp Business"
}
}
}
@@ -225,13 +225,13 @@
"WHATSAPP_EMBEDDED": "WhatsApp Business",
"TWILIO": "Twilio",
"WHATSAPP_CLOUD": "Cloud do WhatsApp",
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
"TWILIO_DESC": "Connect via Twilio credentials",
"WHATSAPP_CLOUD_DESC": "Configuração rápida via Meta",
"TWILIO_DESC": "Conectar através de credenciais Twilio",
"360_DIALOG": "360Dialog"
},
"SELECT_PROVIDER": {
"TITLE": "Select your API provider",
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
"TITLE": "Selecione seu provedor de API",
"DESCRIPTION": "Escolha seu provedor do WhatsApp. Você pode se conectar diretamente através de metade, que não requer nenhuma configuração ou se conectar pelo Twilio usando as credenciais da sua conta."
},
"INBOX_NAME": {
"LABEL": "Nome da Caixa de Entrada",
@@ -272,74 +272,74 @@
},
"SUBMIT_BUTTON": "Criar canal do WhatsApp",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"TITLE": "Configuração rápida com Meta",
"DESC": "Você será redirecionado para a Meta para entrar na sua conta do WhatsApp Business. Ter acesso administrativo ajudará a facilitar a instalação.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
"SECURE_AUTH": "Secure OAuth based authentication",
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
"TITLE": "Benefícios da inscrição incorporada:",
"EASY_SETUP": "Nenhuma configuração manual é necessária",
"SECURE_AUTH": "Autenticação segura baseada em OAuth",
"AUTO_CONFIG": "Configuração automática de webhook e número de telefone"
},
"LEARN_MORE": {
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
"LINK_TEXT": "this link.",
"TEXT": "Para saber mais sobre inscrições integradas, preços e limitações visite",
"LINK_TEXT": "este link.",
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account",
"LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
"WAITING_FOR_AUTH": "Waiting for authentication...",
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
"SUBMIT_BUTTON": "Conecte-se com WhatsApp Business",
"AUTH_PROCESSING": "Autenticando com Meta",
"WAITING_FOR_BUSINESS_INFO": "Por favor, complete a configuração do negócio na janela da Meta...",
"PROCESSING": "Configurando sua conta do WhatsApp Business",
"LOADING_SDK": "Carregando SDK do Facebook...",
"CANCELLED": "A inscrição no WhatsApp foi cancelada",
"SUCCESS_TITLE": "Conta do WhatsApp Business conectada!",
"WAITING_FOR_AUTH": "Aguardando autenticação...",
"INVALID_BUSINESS_DATA": "Dados de negócio inválidos recebidos do Facebook. Por favor, tente novamente.",
"SIGNUP_ERROR": "Ocorreu um erro no cadastro",
"AUTH_NOT_COMPLETED": "Autenticação não concluída. Por favor, reinicie o processo.",
"SUCCESS_FALLBACK": "A conta do WhatsApp Business foi configurada com sucesso"
},
"API": {
"ERROR_MESSAGE": "Não foi possível salvar o canal do WhatsApp"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"TITLE": "Canal de Voz",
"DESC": "Integre o Twilio Voice e comece a oferecer suporte a seus clientes através de chamadas telefônicas.",
"PHONE_NUMBER": {
"LABEL": "Número de Telefone",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
"PLACEHOLDER": "Digite seu número de telefone (por exemplo, +551234567890)",
"ERROR": "Por favor, forneça um número de telefone válido no formato E.164 (por exemplo, +551234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "SID da Conta",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
"PLACEHOLDER": "Insira o SID da sua Conta Twilio",
"REQUIRED": "O SID da conta é necessário"
},
"AUTH_TOKEN": {
"LABEL": "Token de autenticação",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
"PLACEHOLDER": "Por favor, digite seu Token de Autenticação do Twilio",
"REQUIRED": "Um Token de autenticação é necessário"
},
"API_KEY_SID": {
"LABEL": "Chave da API SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
"PLACEHOLDER": "Insira sua chave de API do Twilio SID",
"REQUIRED": "API Key SID é obrigatório"
},
"API_KEY_SECRET": {
"LABEL": "Segredo da Chave API",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
"PLACEHOLDER": "Digite o segredo da sua chave de API do Twilio",
"REQUIRED": "Segredo da chave da API é obrigatório"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
"PLACEHOLDER": "Insira seu Twilio TwiML App SID (começa com AP)",
"REQUIRED": "TwiML App SID é obrigatório"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"SUBMIT_BUTTON": "Criar Canal de Voz",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
"ERROR_MESSAGE": "Não conseguimos criar o canal de voz"
}
},
"API_CHANNEL": {
@@ -603,27 +603,27 @@
"WHATSAPP_SECTION_UPDATE_TITLE": "Atualizar Chave de API",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Digite a nova chave de API aqui",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Atualizar",
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "WhatsApp Embedded Signup",
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "Inscrição incorporada do WhatsApp",
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "Esta caixa de entrada está conectada através da inscrição incorporada do WhatsApp.",
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "Você pode reconfigurar esta caixa de entrada para atualizar suas configurações do WhatsApp Business.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigurar",
"WHATSAPP_CONNECT_TITLE": "Conectar ao WhatsApp Business",
"WHATSAPP_CONNECT_SUBHEADER": ".",
"WHATSAPP_CONNECT_DESCRIPTION": "Conecte esta caixa de entrada ao WhatsApp Business para ter recursos aprimorados e um gerenciamento mais fácil.",
"WHATSAPP_CONNECT_BUTTON": "Conectar",
"WHATSAPP_CONNECT_SUCCESS": "Successfully connected to WhatsApp Business!",
"WHATSAPP_CONNECT_ERROR": "Failed to connect to WhatsApp Business. Please try again.",
"WHATSAPP_RECONFIGURE_SUCCESS": "Successfully reconfigured WhatsApp Business!",
"WHATSAPP_RECONFIGURE_ERROR": "Failed to reconfigure WhatsApp Business. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"WHATSAPP_LOGIN_CANCELLED": "WhatsApp login was cancelled. Please try again.",
"WHATSAPP_CONNECT_SUCCESS": "Conectado com sucesso ao WhatsApp Business!",
"WHATSAPP_CONNECT_ERROR": "Não foi possível reconfigurar o WhatsApp Business. Tente novamente.",
"WHATSAPP_RECONFIGURE_SUCCESS": "WhatsApp Business reconfigurado com sucesso!",
"WHATSAPP_RECONFIGURE_ERROR": "Não foi possível reconfigurar o WhatsApp Business. Tente novamente.",
"WHATSAPP_APP_ID_MISSING": "O ID do WhatsApp não está configurado. Por favor, contate o administrador.",
"WHATSAPP_CONFIG_ID_MISSING": "O ID de Configuração do WhatsApp não está configurado. Por favor, contate o administrador.",
"WHATSAPP_LOGIN_CANCELLED": "O login do WhatsApp foi cancelado. Por favor, tente novamente.",
"WHATSAPP_WEBHOOK_TITLE": "Token de verificação Webhook",
"WHATSAPP_WEBHOOK_SUBHEADER": "Este token é usado para verificar a autenticidade do webhook endpoint.",
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sincronizar Modelos",
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Sincronize manualmente os modelos de mensagens do WhatsApp para atualizar seus modelos disponíveis.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sincronizar Modelos",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Sincronização de modelos iniciada com sucesso. Pode demorar alguns minutos para atualizar.",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Atualizar configurações do Formulário Pre Chat"
},
"HELP_CENTER": {
@@ -883,7 +883,7 @@
"LINE": "Line",
"API": "Canal da API",
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
"VOICE": "Voz"
}
}
}
@@ -334,8 +334,8 @@
},
"NOTION": {
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
"TITLE": "Você tem certeza que deseja excluir a integração com Notion?",
"MESSAGE": "Excluir essa integração removerá o acesso ao seu espaço de trabalho Notion e encerrará todas as funcionalidades relacionadas.",
"CONFIRM": "Sim, excluir",
"CANCEL": "Cancelar"
}
@@ -473,7 +473,7 @@
"TITLE": "Funcionalidades",
"ALLOW_CONVERSATION_FAQS": "Gerar perguntas frequentes a partir de conversas resolvidas",
"ALLOW_MEMORIES": "Capture os principais detalhes como memórias de interações do cliente.",
"ALLOW_CITATIONS": "Include source citations in responses"
"ALLOW_CITATIONS": "Incluir fonte de citações nas respostas"
}
},
"EDIT": {
@@ -487,28 +487,28 @@
"ASSISTANT": "Assistente"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
"TITLE": "Configurações básicas",
"DESCRIPTION": "Personalize o que o assistente diz quando termina uma conversa ou transfere para um humano."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
"TITLE": "Configurações do sistema",
"DESCRIPTION": "Personalize o que o assistente diz quando termina uma conversa ou transfere para um humano."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrailscenarios → output) Nudges user to actually utilise these.",
"TITLE": "As Coisas Divertidas",
"DESCRIPTION": "Adicione mais controle ao assistente. (algo mais visual como uma história: Consulta → cenários → saída) Força o usuário para realmente utilizá-los.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
"TITLE": "Proteções",
"DESCRIPTION": "Mantém as coisas no caminho — apenas os tipos de perguntas que você quer que seu assistente responda, nada fora de limites ou fora do tópico."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
"TITLE": "Cenários",
"DESCRIPTION": "Dê algum contexto ao seu assistente — como \"o que fazer quando um usuário estiver com problemas\", ou \"como agir durante uma solicitação de reembolso\"."
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistants replies—clear and friendly? Short and snappy? Detailed and formal?"
"TITLE": "Diretrizes de resposta",
"DESCRIPTION": "O jeito e a estrutura das respostas do seu assistente — tranquilo e amigável? Curto e ágil? Detalhado e formal?"
}
}
}
@@ -527,138 +527,138 @@
}
},
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic.",
"TITLE": "Proteções",
"DESCRIPTION": "Mantém as coisas no caminho — apenas os tipos de perguntas que você quer que seu assistente responda, nada fora de limites ou fora do tópico.",
"BREADCRUMB": {
"TITLE": "Guardrails"
"TITLE": "Proteções"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECTED": "{count} item selecionado | {count} itens selecionados",
"SELECT_ALL": "Selecionar todos ({count})",
"UNSELECT_ALL": "Desmarcar todos ({count})",
"BULK_DELETE_BUTTON": "Excluir"
},
"ADD": {
"SUGGESTED": {
"TITLE": "Example guardrails",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"SAVE": "Add and save (↵)",
"PLACEHOLDER": "Type in another guardrail..."
"TITLE": "Exemplos de proteções",
"ADD": "Adicionar todos",
"ADD_SINGLE": "Adicionar este",
"SAVE": "Adicionar e salvar (↵)",
"PLACEHOLDER": "Escreva outra proteção"
},
"NEW": {
"TITLE": "Add a guardrail",
"TITLE": "Adicionar proteção",
"CREATE": "Criar",
"CANCEL": "Cancelar",
"PLACEHOLDER": "Type in another guardrail...",
"TEST_ALL": "Test all"
"PLACEHOLDER": "Escreva outra proteção",
"TEST_ALL": "Testar tudo"
}
},
"LIST": {
"SEARCH_PLACEHOLDER": "Pesquisar..."
},
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No guardrails found for this search.",
"EMPTY_MESSAGE": "Nenhuma proteção encontrada. Crie uma ou adicione exemplos para começar.",
"SEARCH_EMPTY_MESSAGE": "Nenhuma proteção encontrada para essa pesquisa.",
"API": {
"ADD": {
"SUCCESS": "Guardrails added successfully",
"ERROR": "There was an error adding guardrails, please try again."
"SUCCESS": "Proteções adicionadas com sucesso",
"ERROR": "Ocorreu um erro ao adicionar as proteções. Por favor, tente novamente."
},
"UPDATE": {
"SUCCESS": "Guardrails updated successfully",
"ERROR": "There was an error updating guardrails, please try again."
"SUCCESS": "Proteções atualizados com sucesso",
"ERROR": "Ocorreu um erro ao atualizar as proteções. Por favor, tente novamente."
},
"DELETE": {
"SUCCESS": "Guardrails deleted successfully",
"ERROR": "There was an error deleting guardrails, please try again."
"SUCCESS": "Proteções removidas com sucesso",
"ERROR": "Ocorreu um erro ao excluir as proteções, por favor, tente novamente."
}
}
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response Guidelines",
"DESCRIPTION": "The vibe and structure of your assistants replies—clear and friendly? Short and snappy? Detailed and formal?",
"TITLE": "Diretrizes de Resposta",
"DESCRIPTION": "O jeito e a estrutura das respostas do seu assistente — tranquilo e amigável? Curto e ágil? Detalhado e formal?",
"BREADCRUMB": {
"TITLE": "Response Guidelines"
"TITLE": "Diretrizes de Resposta"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECTED": "{count} item selecionado | {count} itens selecionados",
"SELECT_ALL": "Selecionar todos ({count})",
"UNSELECT_ALL": "Desmarcar todos ({count})",
"BULK_DELETE_BUTTON": "Excluir"
},
"ADD": {
"SUGGESTED": {
"TITLE": "Example response guidelines",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"SAVE": "Add and save (↵)",
"PLACEHOLDER": "Type in another response guideline..."
"TITLE": "Exemplos de diretrizes de resposta",
"ADD": "Adicionar todos",
"ADD_SINGLE": "Adicionar este",
"SAVE": "Adicionar e salvar (↵)",
"PLACEHOLDER": "Escreva uma outra diretriz de resposta..."
},
"NEW": {
"TITLE": "Add a response guideline",
"TITLE": "Adicione uma diretriz de resposta",
"CREATE": "Criar",
"CANCEL": "Cancelar",
"PLACEHOLDER": "Type in another response guideline...",
"TEST_ALL": "Test all"
"PLACEHOLDER": "Escreva uma outra diretriz de resposta...",
"TEST_ALL": "Testar tudo"
}
},
"LIST": {
"SEARCH_PLACEHOLDER": "Pesquisar..."
},
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No response guidelines found for this search.",
"EMPTY_MESSAGE": "Nenhuma diretriz de resposta encontrada. Crie uma ou adicione exemplos para começar.",
"SEARCH_EMPTY_MESSAGE": "Nenhuma diretriz de resposta encotrada para essa pesquisa.",
"API": {
"ADD": {
"SUCCESS": "Response Guidelines added successfully",
"ERROR": "There was an error adding response guidelines, please try again."
"SUCCESS": "Diretrizes de resposta adicionadas com sucesso",
"ERROR": "Houve um erro ao adicionar diretrizes de resposta, por favor, tente novamente."
},
"UPDATE": {
"SUCCESS": "Response Guidelines updated successfully",
"ERROR": "There was an error updating response guidelines, please try again."
"SUCCESS": "Diretrizes de Resposta atualizadas com sucesso",
"ERROR": "Houve um erro ao atualizar as diretrizes de resposta, por favor, tente novamente."
},
"DELETE": {
"SUCCESS": "Response Guidelines deleted successfully",
"ERROR": "There was an error deleting response guidelines, please try again."
"SUCCESS": "Diretrizes de resposta removidas com sucesso",
"ERROR": "Houve um erro ao excluir as diretrizes de resposta, por favor, tente novamente."
}
}
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
"TITLE": "Cenários",
"DESCRIPTION": "Dê algum contexto ao seu assistente — como \"o que fazer quando um usuário estiver com problemas\", ou \"como agir durante uma solicitação de reembolso\".",
"BREADCRUMB": {
"TITLE": "Scenarios"
"TITLE": "Cenários"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECTED": "{count} item selecionado | {count} itens selecionados",
"SELECT_ALL": "Selecionar todos ({count})",
"UNSELECT_ALL": "Desmarcar todos ({count})",
"BULK_DELETE_BUTTON": "Excluir"
},
"ADD": {
"SUGGESTED": {
"TITLE": "Example scenarios",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"TOOLS_USED": "Tools used :"
"TITLE": "Exemplos de cenários",
"ADD": "Adicionar todos",
"ADD_SINGLE": "Adicionar este",
"TOOLS_USED": "Ferramentas usadas :"
},
"NEW": {
"CREATE": "Add a scenario",
"TITLE": "Create a scenario",
"CREATE": "Adicionar um cenário",
"TITLE": "Criar um cenário",
"FORM": {
"TITLE": {
"LABEL": "Título",
"PLACEHOLDER": "Enter a name for the scenario",
"ERROR": "Scenario name is required"
"PLACEHOLDER": "Digite um nome para o cenário",
"ERROR": "O nome do cenário é obrigatório"
},
"DESCRIPTION": {
"LABEL": "Descrição",
"PLACEHOLDER": "Describe how and where this scenario will be used",
"ERROR": "Scenario description is required"
"PLACEHOLDER": "Descreva como e onde este cenário será utilizado",
"ERROR": "Descrição do cenário é obrigatória"
},
"INSTRUCTION": {
"LABEL": "How to handle",
"PLACEHOLDER": "Describe how and where this scenario will be handled",
"ERROR": "Scenario content is required"
"LABEL": "Como lidar",
"PLACEHOLDER": "Descreva como e onde este cenário será utilizado",
"ERROR": "Conteúdo do cenário é obrigatório"
},
"CREATE": "Criar",
"CANCEL": "Cancelar"
@@ -667,25 +667,25 @@
},
"UPDATE": {
"CANCEL": "Cancelar",
"UPDATE": "Update changes"
"UPDATE": "Atualizar alterações"
},
"LIST": {
"SEARCH_PLACEHOLDER": "Pesquisar..."
},
"EMPTY_MESSAGE": "No scenarios found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No scenarios found for this search.",
"EMPTY_MESSAGE": "Nenhum cenário encontrado. Crie ou adicione exemplos para começar.",
"SEARCH_EMPTY_MESSAGE": "Nenhum cenário encontrado para esta pesquisa.",
"API": {
"ADD": {
"SUCCESS": "Scenarios added successfully",
"ERROR": "There was an error adding scenarios, please try again."
"SUCCESS": "Cenários adicionados com sucesso",
"ERROR": "Ocorreu um erro ao adicionar cenários, por favor tente novamente."
},
"UPDATE": {
"SUCCESS": "Scenarios updated successfully",
"ERROR": "There was an error updating scenarios, please try again."
"SUCCESS": "Cenários atualizados com sucesso",
"ERROR": "Ocorreu um erro ao atualizar cenários, por favor tente novamente."
},
"DELETE": {
"SUCCESS": "Scenarios deleted successfully",
"ERROR": "There was an error deleting scenarios, please try again."
"SUCCESS": "Cenários excluídos com sucesso",
"ERROR": "Ocorreu um erro ao excluir os cenários, por favor tente novamente."
}
}
}
@@ -3,22 +3,22 @@
"MODAL": {
"TITLE": "Templates do Whatsapp",
"SUBTITLE": "Selecione o template do whatsapp que você deseja enviar",
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
"TEMPLATE_SELECTED_SUBTITLE": "Configurar modelo: {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Pesquisar modelos",
"NO_TEMPLATES_FOUND": "Não há templates encontrados para",
"HEADER": "Header",
"BODY": "Body",
"FOOTER": "Footer",
"BUTTONS": "Buttons",
"HEADER": "Cabeçalho",
"BODY": "Corpo",
"FOOTER": "Rodapé",
"BUTTONS": "Botões",
"CATEGORY": "Categoria",
"MEDIA_CONTENT": "Media Content",
"MEDIA_CONTENT_FALLBACK": "media content",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"MEDIA_CONTENT": "Conteúdo de Mídia",
"MEDIA_CONTENT_FALLBACK": "conteúdo de mídia",
"NO_TEMPLATES_AVAILABLE": "Não há modelos disponíveis do WhatsApp. Clique em atualizar para sincronizar os modelos do WhatsApp.",
"REFRESH_BUTTON": "Atualizar modelos",
"REFRESH_SUCCESS": "Atualização de modelos iniciada. Pode levar alguns minutos para atualizar.",
"REFRESH_ERROR": "Falha ao atualizar os modelos. Por favor, tente novamente.",
"LABELS": {
"LANGUAGE": "Idioma",
"TEMPLATE_BODY": "Conteúdo do Template",
@@ -33,14 +33,14 @@
"GO_BACK_LABEL": "Voltar",
"SEND_MESSAGE_LABEL": "Enviar Mensagem",
"FORM_ERROR_MESSAGE": "Por favor, preencha todas as variáveis antes de enviar",
"MEDIA_HEADER_LABEL": "{type} Header",
"OTP_CODE": "Enter 4-8 digit OTP",
"EXPIRY_MINUTES": "Enter expiry minutes",
"BUTTON_PARAMETERS": "Button Parameters",
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"BUTTON_PARAMETER": "Enter button parameter"
"MEDIA_HEADER_LABEL": "Cabeçalho {type}",
"OTP_CODE": "Digite OTP de 4 a 8 dígitos",
"EXPIRY_MINUTES": "Digite os minutos de expiração",
"BUTTON_PARAMETERS": "Parâmetros do botão",
"BUTTON_LABEL": "Botão {index}",
"COUPON_CODE": "Digite o código do cupom (máx. 15 caracteres)",
"MEDIA_URL_LABEL": "Digite a URL {type}",
"BUTTON_PARAMETER": "Insira o parâmetro do botão"
}
}
}
@@ -6,6 +6,7 @@ import { picoSearch } from '@scmmishra/pico-search';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -20,6 +21,7 @@ const { t } = useI18n();
const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const { formatMessage } = useMessageFormatter();
const assistantId = route.params.assistantId;
const uiFlags = useMapGetter('captainScenarios/getUIFlags');
@@ -42,35 +44,25 @@ const breadcrumbItems = computed(() => {
];
});
const TOOL_LINK_REGEX = /\[([^\]]+)]\(tool:\/\/.+?\)/g;
const LINK_INSTRUCTION_CLASS =
'[&_a[href^="tool://"]]:text-n-iris-11 [&_a:not([href^="tool://"])]:text-n-slate-12 [&_a]:pointer-events-none [&_a]:cursor-default';
const renderInstruction = instruction => () =>
h('span', {
class: 'text-sm text-n-slate-12 py-4',
innerHTML: instruction.replace(
TOOL_LINK_REGEX,
(_, title) =>
`<span class="text-n-iris-11 font-medium">@${title.replace(/^@/, '')}</span>`
),
class: `text-sm text-n-slate-12 py-4 prose prose-sm min-w-0 break-words ${LINK_INSTRUCTION_CLASS}`,
innerHTML: instruction,
});
// Suggested example scenarios for quick add
const scenariosExample = [
{
id: 1,
title: 'Refund Order',
description: 'User encountered a technical issue or error message.',
title: 'Prospective Buyer',
description:
'Handle customers who are showing interest in purchasing a license',
instruction:
'Ask for steps to reproduce + browser/app version. Use [Known Issues](tool://known_issues) to check if its a known bug. File with [Create Bug Report](tool://bug_report_create) if new.',
tools: ['create_bug_report', 'known_issues'],
},
{
id: 2,
title: 'Product Recommendation',
description: 'User is unsure which product or service to choose.',
instruction:
'Ask 23 clarifying questions. Use [Product Match](tool://product_match[user_needs]) and suggest 23 options with pros/cons. Link to compare page if available.',
tools: ['product_match[user_needs]'],
'If someone is interested in purchasing a license, ask them for following:\n\n1. How many licenses are they willing to purchase?\n2. Are they migrating from another platform?\n. Once these details are collected, do the following steps\n1. add a private note to with the information you collected using [Add Private Note](tool://add_private_note)\n2. Add label "sales" to the contact using [Add Label to Conversation](tool://add_label_to_conversation)\n3. Reply saying "one of us will reach out soon" and provide an estimated timeline for the response and [Handoff to Human](tool://handoff)',
tools: ['add_private_note', 'add_label_to_conversation', 'handoff'],
},
];
@@ -248,7 +240,9 @@ onMounted(() => {
<span class="text-sm text-n-slate-11 mt-2">
{{ item.description }}
</span>
<component :is="renderInstruction(item.instruction)" />
<component
:is="renderInstruction(formatMessage(item.instruction, false))"
/>
<span class="text-sm text-n-slate-11 font-medium mb-1">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
{{ item.tools?.map(tool => `@${tool}`).join(', ') }}
@@ -11,6 +11,7 @@ import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import NextButton from 'dashboard/components-next/button/Button.vue';
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
import {
isAConversationRoute,
@@ -28,6 +29,7 @@ export default {
ComposeConversation,
SocialIcons,
ContactMergeModal,
VoiceCallButton,
},
props: {
contact: {
@@ -278,6 +280,14 @@ export default {
/>
</template>
</ComposeConversation>
<VoiceCallButton
:phone="contact.phone_number"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
slate
faded
/>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
icon="i-ph-pencil-simple"
@@ -55,15 +55,8 @@ export default {
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
},
methods: {
...mapActions('conversation', [
'sendMessage',
'sendAttachment',
'clearConversations',
]),
...mapActions('conversationAttributes', [
'getAttributes',
'clearConversationAttributes',
]),
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
...mapActions('conversationAttributes', ['getAttributes']),
async handleSendMessage(content) {
await this.sendMessage({
content,
@@ -84,8 +77,6 @@ export default {
this.inReplyTo = null;
},
startNewConversation() {
this.clearConversations();
this.clearConversationAttributes();
this.replaceRoute('prechat-form');
IFrameHelper.sendMessage({
event: 'onEvent',
+1 -1
View File
@@ -14,7 +14,7 @@
},
"THUMBNAIL": {
"AUTHOR": {
"NOT_AVAILABLE": "Not available"
"NOT_AVAILABLE": "Indisponível"
}
},
"TEAM_AVAILABILITY": {
@@ -1,4 +1,5 @@
<script>
import { mapActions } from 'vuex';
import PreChatForm from '../components/PreChat/Form.vue';
import configMixin from '../mixins/configMixin';
import routerMixin from '../mixins/routerMixin';
@@ -19,6 +20,8 @@ export default {
emitter.off(ON_CONVERSATION_CREATED, this.handleConversationCreated);
},
methods: {
...mapActions('conversation', ['clearConversations']),
...mapActions('conversationAttributes', ['clearConversationAttributes']),
handleConversationCreated() {
// Redirect to messages page after conversation is created
this.replaceRoute('messages');
@@ -48,6 +51,8 @@ export default {
},
});
} else {
this.clearConversations();
this.clearConversationAttributes();
this.$store.dispatch('conversation/createConversation', {
fullName: fullName,
emailAddress: emailAddress,
-36
View File
@@ -1,36 +0,0 @@
# frozen_string_literal: true
class AssignmentV2::AssignmentJob < ApplicationJob
queue_as :low
def perform(inbox_id: nil, conversation_id: nil)
if conversation_id
assign_single_conversation(conversation_id)
elsif inbox_id
assign_inbox_conversations(inbox_id)
else
Rails.logger.error 'AssignmentV2::AssignmentJob: No inbox_id or conversation_id provided'
end
end
private
def assign_single_conversation(conversation_id)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
service = AssignmentV2::AssignmentService.new(inbox: conversation.inbox)
service.perform_for_conversation(conversation)
end
def assign_inbox_conversations(inbox_id)
inbox = Inbox.find_by(id: inbox_id)
return unless inbox
return unless inbox.assignment_v2_enabled?
service = AssignmentV2::AssignmentService.new(inbox: inbox)
assigned_count = service.perform_bulk_assignment
Rails.logger.info "AssignmentV2::AssignmentJob: Assigned #{assigned_count} conversations for inbox #{inbox_id}"
end
end
+7 -29
View File
@@ -1,11 +1,9 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: assignment_policies
#
# id :bigint not null, primary key
# assignment_order :integer default("round_robin"), not null
# assignment_order :integer default(0), not null
# conversation_priority :integer default("earliest_created"), not null
# description :text
# enabled :boolean default(TRUE), not null
@@ -22,38 +20,18 @@
# index_assignment_policies_on_account_id_and_name (account_id,name) UNIQUE
# index_assignment_policies_on_enabled (enabled)
#
class AssignmentPolicy < ApplicationRecord
# Enums
enum assignment_order: { round_robin: 0 }
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
# Associations
belongs_to :account
has_many :inbox_assignment_policies, dependent: :destroy
has_many :inboxes, through: :inbox_assignment_policies
# Validations
validates :name, presence: true, uniqueness: { scope: :account_id }
validates :name, length: { maximum: 255 }
validates :description, length: { maximum: 1000 }
validates :fair_distribution_limit, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
validates :fair_distribution_window, presence: true, numericality: { greater_than: 60, less_than_or_equal_to: 86_400 }
validates :assignment_order, inclusion: { in: assignment_orders.keys }
validates :conversation_priority, inclusion: { in: conversation_priorities.keys }
validates :fair_distribution_limit, numericality: { greater_than: 0 }
validates :fair_distribution_window, numericality: { greater_than: 0 }
def webhook_data
{
id: id,
name: name,
description: description,
assignment_order: assignment_order,
conversation_priority: conversation_priority,
fair_distribution_limit: fair_distribution_limit,
fair_distribution_window: fair_distribution_window,
enabled: enabled
}
end
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
enum assignment_order: { round_robin: 0 } unless ChatwootApp.enterprise?
end
AssignmentPolicy.prepend_mod_with('AssignmentPolicy')
AssignmentPolicy.include_mod_with('Concerns::AssignmentPolicy')
@@ -14,18 +14,11 @@ module AutoAssignmentHandler
return unless conversation_status_changed_to_open?
return unless should_run_auto_assignment?
if inbox.assignment_v2_enabled?
# Use Assignment V2 system
AssignmentV2::AssignmentJob.perform_later(conversation_id: id)
else
# Use legacy assignment system
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
end
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
end
def should_run_auto_assignment?
# Check auto assignment is enabled (either legacy or v2)
return false unless inbox.auto_assignment_enabled?
return false unless inbox.enable_auto_assignment?
# run only if assignee is blank or doesn't have access to inbox
assignee.blank? || inbox.members.exclude?(assignee)
@@ -1,57 +0,0 @@
# frozen_string_literal: true
module InboxAgentAvailability
extend ActiveSupport::Concern
def available_agents(options = {})
# Get online agent IDs
online_agent_ids = fetch_online_agent_ids
return inbox_members.none if online_agent_ids.empty?
# Base query - only online agents
scope = build_online_agents_scope(online_agent_ids)
# Apply filters
apply_agent_filters(scope, options)
end
def member_ids_with_assignment_capacity
member_ids
end
private
def build_online_agents_scope(online_agent_ids)
inbox_members
.joins(:user)
.where(users: { id: online_agent_ids })
.includes(:user)
end
def apply_agent_filters(scope, options)
# Exclude specific users if requested
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
# Apply rate limiting if assignment policy is enabled
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
scope
end
def fetch_online_agent_ids
OnlineStatusTracker.get_available_users(account_id)
.select { |_key, value| value.eql?('online') }
.keys
.map(&:to_i)
end
def filter_by_rate_limits(inbox_members_scope)
# Filter out agents who have exceeded rate limits
return inbox_members_scope unless assignment_policy&.enabled?
inbox_members_scope.select do |inbox_member|
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
rate_limiter.within_limits?
end
end
end
+2 -18
View File
@@ -44,7 +44,6 @@ class Inbox < ApplicationRecord
include Avatarable
include OutOfOffisable
include AccountCacheRevalidator
include InboxAgentAvailability
# Not allowing characters:
validates :name, presence: true
@@ -68,15 +67,13 @@ class Inbox < ApplicationRecord
has_many :conversations, dependent: :destroy_async
has_many :messages, dependent: :destroy_async
has_one :inbox_assignment_policy, dependent: :destroy
has_one :assignment_policy, through: :inbox_assignment_policy
has_one :agent_bot_inbox, dependent: :destroy_async
has_one :agent_bot, through: :agent_bot_inbox
has_many :webhooks, dependent: :destroy_async
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
# Assignment V2 associations
has_one :inbox_assignment_policy, dependent: :destroy
has_one :assignment_policy, through: :inbox_assignment_policy
enum sender_name_type: { friendly: 0, professional: 1 }
after_destroy :delete_round_robin_agents
@@ -189,19 +186,6 @@ class Inbox < ApplicationRecord
members.ids
end
# Assignment V2 methods
def assignment_v2_enabled?
account.feature_enabled?('assignment_v2') && assignment_policy.present? && assignment_policy.enabled?
end
def auto_assignment_enabled?
if assignment_v2_enabled?
assignment_policy.present? && assignment_policy.enabled?
else
enable_auto_assignment?
end
end
private
def default_name_for_blank_name
-31
View File
@@ -1,5 +1,3 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: inbox_assignment_policies
@@ -15,38 +13,9 @@
# index_inbox_assignment_policies_on_assignment_policy_id (assignment_policy_id)
# index_inbox_assignment_policies_on_inbox_id (inbox_id) UNIQUE
#
class InboxAssignmentPolicy < ApplicationRecord
# Associations
belongs_to :inbox
belongs_to :assignment_policy
# Validations
validates :inbox_id, uniqueness: true
validate :inbox_belongs_to_same_account
# Delegations
delegate :account, to: :inbox
delegate :name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled?,
to: :assignment_policy, prefix: :policy
def webhook_data
{
id: id,
inbox_id: inbox_id,
assignment_policy_id: assignment_policy_id,
policy: assignment_policy.webhook_data
}
end
private
def inbox_belongs_to_same_account
return unless inbox && assignment_policy
return if inbox.account_id == assignment_policy.account_id
errors.add(:inbox, 'must belong to the same account as the assignment policy')
end
end
-2
View File
@@ -1,5 +1,3 @@
# frozen_string_literal: true
class AssignmentPolicyPolicy < ApplicationPolicy
def index?
@account_user.administrator?
@@ -1,102 +0,0 @@
# frozen_string_literal: true
class AssignmentV2::AssignmentService
pattr_initialize [:inbox!]
def perform_for_conversation(conversation)
return false unless can_assign?(conversation)
agent = find_agent_for_conversation(conversation)
return false unless agent
assign_conversation_to_agent(conversation, agent)
end
def perform_bulk_assignment(limit: 50)
return 0 unless assignment_enabled?
conversations = unassigned_conversations(limit)
assigned_count = 0
conversations.find_each do |conversation|
assigned_count += 1 if perform_for_conversation(conversation)
end
assigned_count
end
private
def policy
@policy ||= inbox.assignment_policy
end
def assignment_enabled?
policy&.enabled?
end
def can_assign?(conversation)
assignment_enabled? &&
conversation.status == 'open' &&
conversation.assignee_id.nil?
end
def find_agent_for_conversation(_conversation)
available_agents = inbox.available_agents(check_rate_limits: true)
if available_agents.empty?
log_no_agents_available
return nil
end
selector_service.select_agent(available_agents)
end
def selector_service
@selector_service ||= AssignmentV2::RoundRobinSelector.new(inbox: inbox)
end
def unassigned_conversations(limit)
scope = inbox.conversations
.unassigned
.open
# Apply conversation priority ordering
scope = case policy.conversation_priority
when 'longest_waiting'
scope.order(last_activity_at: :asc, created_at: :asc)
else
scope.order(created_at: :asc)
end
scope.limit(limit)
end
def assign_conversation_to_agent(conversation, agent)
conversation.update!(assignee: agent)
create_assignment_activity(conversation, agent)
true
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "AssignmentV2: Failed to assign conversation #{conversation.id}: #{e.message}"
false
end
def create_assignment_activity(conversation, agent)
Rails.configuration.dispatcher.dispatch(
Events::Types::ASSIGNEE_CHANGED,
Time.zone.now,
conversation: conversation,
user: agent
)
end
def enterprise_enabled?
@enterprise_enabled ||= defined?(Enterprise)
end
def log_no_agents_available
Rails.logger.warn("AssignmentV2: No agents available for inbox #{inbox.id}")
end
end
AssignmentV2::AssignmentService.prepend_mod_with('AssignmentV2::AssignmentService')
@@ -1,75 +0,0 @@
# frozen_string_literal: true
# Rate limiter for assignment operations
# Uses SQL to track assignment counts per agent per time window
# based on assignment policy's fair_distribution_limit and fair_distribution_window
class AssignmentV2::RateLimiter
pattr_initialize [:inbox!, :user!]
# Check if the user has exceeded rate limits
# @return [Boolean] true if within limits, false if exceeded
def within_limits?
return true unless policy_exists?
current_count < rate_limit
end
# Get current rate limit status for the user
# @return [Hash] Rate limit status information
def status
if policy_exists?
{
within_limits: within_limits?,
current_count: current_count,
limit: rate_limit,
reset_at: Time.zone.at(next_window_start)
}
else
{
within_limits: true,
current_count: 0,
limit: Float::INFINITY,
reset_at: nil
}
end
end
private
def policy
@policy ||= inbox.assignment_policy
end
def policy_exists?
policy.present? && policy.enabled?
end
def current_count
# Count conversations assigned to this user in the current time window
# from this inbox
window_start = Time.zone.at(current_window)
Conversation
.where(inbox_id: inbox.id)
.where(assignee_id: user.id)
.where('updated_at >= ?', window_start)
.where.not(assignee_id: nil)
.count
end
def rate_limit
policy&.fair_distribution_limit || 10
end
def time_window
policy&.fair_distribution_window || 3600
end
def current_window
(Time.current.to_i / time_window) * time_window
end
def next_window_start
current_window + time_window
end
end
@@ -1,37 +0,0 @@
# frozen_string_literal: true
class AssignmentV2::RoundRobinSelector
pattr_initialize [:inbox!]
def select_agent(available_agents)
return nil if available_agents.empty?
# Extract user IDs from inbox members
agent_user_ids = available_agents.map(&:user_id).map(&:to_s)
# Use Redis queue for round robin
selected_user_id = round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
return nil unless selected_user_id
# Return the user object
available_agents.find { |inbox_member| inbox_member.user_id.to_s == selected_user_id }&.user
end
def add_agent_to_queue(user_id)
round_robin_service.add_agent_to_queue(user_id)
end
def remove_agent_from_queue(user_id)
round_robin_service.remove_agent_from_queue(user_id)
end
def reset_queue
round_robin_service.reset_queue
end
private
def round_robin_service
@round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
end
end
@@ -0,0 +1,10 @@
json.id assignment_policy.id
json.name assignment_policy.name
json.description assignment_policy.description
json.assignment_order assignment_policy.assignment_order
json.conversation_priority assignment_policy.conversation_priority
json.fair_distribution_limit assignment_policy.fair_distribution_limit
json.fair_distribution_window assignment_policy.fair_distribution_window
json.enabled assignment_policy.enabled
json.created_at assignment_policy.created_at.to_i
json.updated_at assignment_policy.updated_at.to_i
@@ -0,0 +1 @@
json.partial! 'assignment_policy', assignment_policy: @assignment_policy
@@ -0,0 +1,5 @@
json.id @inbox_assignment_policy.id
json.inbox_id @inbox_assignment_policy.inbox_id
json.assignment_policy_id @inbox_assignment_policy.assignment_policy_id
json.created_at @inbox_assignment_policy.created_at.to_i
json.updated_at @inbox_assignment_policy.updated_at.to_i
@@ -0,0 +1,3 @@
json.inboxes @inboxes do |inbox|
json.partial! 'api/v1/models/inbox', formats: [:json], resource: inbox
end
@@ -0,0 +1,3 @@
json.array! @assignment_policies do |assignment_policy|
json.partial! 'assignment_policy', assignment_policy: assignment_policy
end
@@ -0,0 +1 @@
json.partial! 'assignment_policy', assignment_policy: @assignment_policy
@@ -0,0 +1 @@
json.partial! 'assignment_policy', assignment_policy: @assignment_policy
@@ -0,0 +1 @@
json.partial! 'api/v1/accounts/assignment_policies/assignment_policy', formats: [:json], assignment_policy: @assignment_policy
@@ -0,0 +1 @@
json.partial! 'api/v1/accounts/assignment_policies/assignment_policy', formats: [:json], assignment_policy: @assignment_policy
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.4.0'
version: '4.5.1'
development:
<<: *shared
+1 -1
View File
@@ -12,7 +12,7 @@ Bundler.require(*Rails.groups)
# We rely on DOTENV to load the environment variables
# We need these environment variables to load the specific APM agent
Dotenv::Rails.load
require 'ddtrace' if ENV.fetch('DD_TRACE_AGENT_URL', false).present?
require 'datadog' if ENV.fetch('DD_TRACE_AGENT_URL', false).present?
require 'elastic-apm' if ENV.fetch('ELASTIC_APM_SECRET_TOKEN', false).present?
require 'scout_apm' if ENV.fetch('SCOUT_KEY', false).present?
+1
View File
@@ -194,3 +194,4 @@
- name: assignment_v2
display_name: Assignment V2
enabled: false
chatwoot_internal: true
+5
View File
@@ -53,6 +53,8 @@ en:
email_already_exists: 'You have already signed up for an account with %{email}'
invalid_params: 'Invalid, please check the signup paramters and try again'
failed: Signup failed
assignment_policy:
not_found: Assignment policy not found
data_import:
data_type:
invalid: Invalid data type
@@ -96,6 +98,9 @@ en:
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
custom_attribute_definition:
key_conflict: The provided key is not allowed as it might conflict with default attributes.
leave_records:
cannot_update_non_pending: Cannot update non-pending leave record
cannot_delete: Cannot delete this leave record
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
+15 -15
View File
@@ -20,9 +20,9 @@ pt_BR:
hello: 'Olá, mundo'
inbox:
reauthorization:
success: 'Channel reauthorized successfully'
not_required: 'Reauthorization is not required for this inbox'
invalid_channel: 'Invalid channel type for reauthorization'
success: 'Canal reautenticado com sucesso'
not_required: 'Reautenticação não é necessária para esta caixa de entrada'
invalid_channel: 'Tipo de canal inválido para reautenticar'
messages:
reset_password_success: Legal! A solicitação de alteração de senha foi bem sucedida. Verifique seu e-mail para obter instruções.
reset_password_failure: Uh ho! Não conseguimos encontrar nenhum usuário com o e-mail especificado.
@@ -59,12 +59,12 @@ pt_BR:
slack:
invalid_channel_id: 'Canal de slack inválido. Por favor, tente novamente'
whatsapp:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
token_exchange_failed: 'Falha ao trocar o código por um token de acesso. Por favor, tente novamente.'
invalid_token_permissions: 'O token de acesso não tem as permissões necessárias para o WhatsApp.'
phone_info_fetch_failed: 'Falha ao obter a informação do número de telefone. Por favor, tente novamente.'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
generic: 'Falha ao reautenticar o WhatsApp. Por favor, tente novamente.'
not_supported: 'Reautenticação não é suportado por este tipo de canal WhatsApp.'
inboxes:
imap:
socket_error: Por favor, verifique a conexão de rede, endereço IMAP e tente novamente.
@@ -257,8 +257,8 @@ pt_BR:
description: 'Crie issues em Linear diretamente da sua janela de conversa. Alternativamente, vincule as issues lineares existentes para um processo de rastreamento de problemas mais simples e eficiente.'
notion:
name: 'Notion'
short_description: 'Integrate databases, documents and pages directly with Captain.'
description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.'
short_description: 'Integre banco de dados, documentos e páginas diretamente com o Capitão.'
description: 'Conecte o seu espaço de trabalho Notion para permitir que o Capitão acesse e gere respostas inteligentes usando o conteúdo de seus bancos de dados, documentos e páginas para fornecer suporte ao cliente mais contextual.'
shopify:
name: 'Shopify'
short_description: 'Acessar detalhes do pedido e dados de clientes da sua loja Shopify.'
@@ -359,9 +359,9 @@ pt_BR:
portals:
send_instructions:
email_required: 'E-mail é obrigatório'
invalid_email_format: 'Invalid email format'
custom_domain_not_configured: 'Custom domain is not configured'
instructions_sent_successfully: 'Instructions sent successfully'
subject: 'Finish setting up %{custom_domain}'
invalid_email_format: 'Formato inválido de e-mail'
custom_domain_not_configured: 'Domínio personalizado não está configurado'
instructions_sent_successfully: 'Instruções enviadas com sucesso'
subject: 'Termine de configurar %{custom_domain}'
ssl_status:
custom_domain_not_configured: 'Custom domain is not configured'
custom_domain_not_configured: 'Domínio personalizado não está configurado'
+10 -2
View File
@@ -96,6 +96,12 @@ Rails.application.routes.draw do
post :execute, on: :member
end
resources :sla_policies, only: [:index, :create, :show, :update, :destroy]
resources :leave_records, only: [:index, :create, :show, :update, :destroy] do
member do
patch :approve
patch :reject
end
end
resources :custom_roles, only: [:index, :create, :show, :update, :destroy]
resources :campaigns, only: [:index, :create, :show, :update, :destroy]
resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy]
@@ -218,10 +224,12 @@ Rails.application.routes.draw do
end
# Assignment V2 Routes
resources :assignment_policies
resources :assignment_policies do
resources :inboxes, only: [:index, :create, :destroy], module: :assignment_policies
end
resources :inboxes, only: [] do
resource :assignment_policy, only: [:show, :create, :destroy], controller: 'inbox_assignment_policies'
resource :assignment_policy, only: [:show, :create, :destroy], module: :inboxes
end
namespace :twitter do
@@ -1,5 +1,3 @@
# frozen_string_literal: true
class CreateLeaves < ActiveRecord::Migration[7.1]
def change
create_table :leaves do |t|
@@ -1,5 +1,7 @@
class AddFeatureCitationToAssistantConfig < ActiveRecord::Migration[7.1]
def up
return unless ChatwootApp.enterprise?
Captain::Assistant.find_each do |assistant|
assistant.update!(
config: assistant.config.merge('feature_citation' => true)
@@ -8,6 +10,8 @@ class AddFeatureCitationToAssistantConfig < ActiveRecord::Migration[7.1]
end
def down
return unless ChatwootApp.enterprise?
Captain::Assistant.find_each do |assistant|
config = assistant.config.dup
config.delete('feature_citation')
@@ -0,0 +1,5 @@
class RenameLeaveTableToLeaveRecords < ActiveRecord::Migration[7.1]
def change
rename_table :leaves, :leave_records
end
end
+6 -6
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
ActiveRecord::Schema[7.1].define(version: 2025_08_20_083315) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -848,7 +848,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
end
create_table "leaves", force: :cascade do |t|
create_table "leave_records", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "user_id", null: false
t.date "start_date", null: false
@@ -860,10 +860,10 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
t.datetime "approved_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "status"], name: "index_leaves_on_account_id_and_status"
t.index ["account_id"], name: "index_leaves_on_account_id"
t.index ["approved_by_id"], name: "index_leaves_on_approved_by_id"
t.index ["user_id"], name: "index_leaves_on_user_id"
t.index ["account_id", "status"], name: "index_leave_records_on_account_id_and_status"
t.index ["account_id"], name: "index_leave_records_on_account_id"
t.index ["approved_by_id"], name: "index_leave_records_on_approved_by_id"
t.index ["user_id"], name: "index_leave_records_on_user_id"
end
create_table "macros", force: :cascade do |t|
@@ -0,0 +1,57 @@
class Api::V1::Accounts::LeaveRecordsController < Api::V1::Accounts::EnterpriseAccountsController
before_action :check_authorization
before_action :fetch_leave_record, only: [:show, :update, :destroy, :approve, :reject]
before_action :ensure_pending_status, only: [:update]
before_action :ensure_can_be_cancelled, only: [:destroy]
def index
@leave_records = policy_scope(Current.account.leave_records)
end
def show; end
def create
@leave_record = Current.account.leave_records.create!(permitted_params.merge(user: current_user))
end
def update
@leave_record.update!(permitted_params)
end
def destroy
@leave_record.destroy!
head :ok
end
def approve
@leave_record.approve!(current_user)
render :show
end
def reject
@leave_record.reject!(current_user)
render :show
end
private
def permitted_params
params.require(:leave_record).permit(:start_date, :end_date, :leave_type, :reason)
end
def fetch_leave_record
@leave_record = policy_scope(Current.account.leave_records).find(params[:id])
end
def ensure_pending_status
return if @leave_record.pending?
render_could_not_create_error(I18n.t('errors.leave_records.cannot_update_non_pending'))
end
def ensure_can_be_cancelled
return if @leave_record.can_be_cancelled?
render_could_not_create_error(I18n.t('errors.leave_records.cannot_delete'))
end
end
@@ -1,27 +0,0 @@
module Enterprise::AssignmentPolicy
# In enterprise, we extend the enum to include balanced
# However, since Rails enums are frozen after definition,
# we need to handle this differently
# Override assignment_order= to accept 'balanced'
def assignment_order=(value)
if value.to_s == 'balanced'
write_attribute(:assignment_order, 1)
else
super
end
end
# Override assignment_order getter to return 'balanced' for value 1
def assignment_order
value = read_attribute(:assignment_order)
return 'balanced' if value == 1
super
end
# Define balanced? method
def balanced?
self[:assignment_order] == 1
end
end
@@ -5,6 +5,7 @@ module Enterprise::Concerns::Account
has_many :sla_policies, dependent: :destroy_async
has_many :applied_slas, dependent: :destroy_async
has_many :custom_roles, dependent: :destroy_async
has_many :leave_records, dependent: :destroy_async
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
@@ -0,0 +1,7 @@
module Enterprise::Concerns::AssignmentPolicy
extend ActiveSupport::Concern
included do
enum assignment_order: { round_robin: 0, balanced: 1 } if ChatwootApp.enterprise?
end
end
@@ -6,6 +6,7 @@ module Enterprise::Concerns::User
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
has_many :copilot_threads, dependent: :destroy_async
has_many :leave_records, dependent: :destroy_async
end
def ensure_installation_pricing_plan_quantity
+105
View File
@@ -0,0 +1,105 @@
# == Schema Information
#
# Table name: leave_records
#
# id :bigint not null, primary key
# approved_at :datetime
# end_date :date not null
# leave_type :integer default("annual"), not null
# reason :text
# start_date :date not null
# status :integer default("pending"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# approved_by_id :bigint
# user_id :bigint not null
#
# Indexes
#
# index_leave_records_on_account_id (account_id)
# index_leave_records_on_account_id_and_status (account_id,status)
# index_leave_records_on_approved_by_id (approved_by_id)
# index_leave_records_on_user_id (user_id)
#
class LeaveRecord < ApplicationRecord
belongs_to :account
belongs_to :user
belongs_to :approved_by, class_name: 'User', optional: true
enum leave_type: {
annual: 0,
sick: 1,
personal: 2,
maternity: 3,
paternity: 4,
emergency: 5,
bereavement: 6,
study: 7,
other: 8
}
enum status: {
pending: 0,
approved: 1,
rejected: 2,
cancelled: 3
}
validates :start_date, :end_date, presence: true
validates :leave_type, :status, presence: true
validate :end_date_after_start_date
validate :future_dates_for_pending_leaves
validate :approved_by_is_admin
scope :for_account, ->(account_id) { where(account_id: account_id) }
scope :for_user, ->(user_id) { where(user_id: user_id) }
scope :by_status, ->(status) { where(status: status) }
scope :by_leave_type, ->(leave_type) { where(leave_type: leave_type) }
scope :in_date_range, ->(start_date, end_date) { where('start_date <= ? AND end_date >= ?', end_date, start_date) }
def approve!(approved_by_user)
update!(status: :approved, approved_by_id: approved_by_user.id, approved_at: Time.current)
end
def reject!(approved_by_user)
update!(status: :rejected, approved_by_id: approved_by_user.id, approved_at: Time.current)
end
def duration_in_days
return 0 unless start_date && end_date
(end_date - start_date).to_i + 1
end
def can_be_cancelled?
pending? || (approved? && start_date > Date.current)
end
def overlaps_with?(other_leave)
return false unless other_leave.is_a?(LeaveRecord)
start_date <= other_leave.end_date && end_date >= other_leave.start_date
end
private
def end_date_after_start_date
return unless start_date && end_date
errors.add(:end_date, 'must be after start date') if end_date < start_date
end
def future_dates_for_pending_leaves
return unless pending?
errors.add(:start_date, 'must be in the future') if start_date && start_date <= Date.current
end
def approved_by_is_admin
return unless approved_by_id && approved_by
account_user = account.account_users.find_by(user: approved_by)
errors.add(:approved_by, 'must be an administrator') unless account_user&.administrator?
end
end
@@ -0,0 +1,49 @@
class LeaveRecordPolicy < ApplicationPolicy
def index?
@account_user.administrator? || @account_user.agent?
end
def show?
@account_user.administrator? || @account_user.agent?
end
def create?
@account_user.administrator? || @account_user.agent?
end
def update?
@account_user.administrator? || @account_user.agent?
end
def destroy?
@account_user.administrator? || @account_user.agent?
end
def approve?
@account_user.administrator?
end
def reject?
@account_user.administrator?
end
class Scope
attr_reader :user_context, :user, :scope, :account, :account_user
def initialize(user_context, scope)
@user_context = user_context
@user = user_context[:user]
@account = user_context[:account]
@account_user = user_context[:account_user]
@scope = scope
end
def resolve
if @account_user.administrator?
scope.includes(:user, :approved_by)
else
scope.where(user: @user).includes(:user, :approved_by)
end
end
end
end
@@ -1,10 +0,0 @@
module Enterprise::AssignmentV2::AssignmentService
# Override selector_service to use BalancedSelector when appropriate
def selector_service
@selector_service ||= if policy&.balanced?
Enterprise::AssignmentV2::BalancedSelector.new(inbox: inbox)
else
super
end
end
end
@@ -1,54 +0,0 @@
# frozen_string_literal: true
class Enterprise::AssignmentV2::BalancedSelector
pattr_initialize [:inbox!]
def select_agent(available_agents)
return nil if available_agents.empty?
# Get current assignment counts for all available agents
agent_users = available_agents.map(&:user)
assignment_counts = fetch_assignment_counts(agent_users)
# Find the agent with the least assignments
selected_agent = agent_users.min_by { |user| assignment_counts[user.id] || 0 }
# Log the selection for debugging
Rails.logger.info "BalancedSelector: Selected agent #{selected_agent.id} with #{assignment_counts[selected_agent.id] || 0} assignments"
selected_agent
end
def add_agent_to_queue(user_id)
# No-op for balanced assignment - we don't maintain a queue
end
def remove_agent_from_queue(user_id)
# No-op for balanced assignment - we don't maintain a queue
end
def reset_queue
# No-op for balanced assignment - we don't maintain a queue
end
private
def fetch_assignment_counts(users)
# Get open conversation counts for each user
user_ids = users.map(&:id)
# Count open conversations assigned to each user in this inbox
counts = inbox.conversations
.open
.where(assignee_id: user_ids)
.group(:assignee_id)
.count
# Convert to hash with default value of 0
Hash.new(0).merge(counts)
end
def account
@account ||= inbox.account
end
end
@@ -0,0 +1 @@
json.partial! 'api/v1/models/leave_record', formats: [:json], leave_record: @leave_record
@@ -0,0 +1,3 @@
json.array! @leave_records do |leave_record|
json.partial! 'api/v1/models/leave_record', formats: [:json], leave_record: leave_record
end
@@ -0,0 +1 @@
json.partial! 'api/v1/models/leave_record', formats: [:json], leave_record: @leave_record
@@ -0,0 +1 @@
json.partial! 'api/v1/models/leave_record', formats: [:json], leave_record: @leave_record
@@ -0,0 +1,26 @@
json.id leave_record.id
json.start_date leave_record.start_date
json.end_date leave_record.end_date
json.leave_type leave_record.leave_type
json.status leave_record.status
json.reason leave_record.reason
json.duration_in_days leave_record.duration_in_days
json.approved_at leave_record.approved_at&.to_i
json.created_at leave_record.created_at.to_i
json.updated_at leave_record.updated_at.to_i
json.user do
json.id leave_record.user.id
json.name leave_record.user.name
json.email leave_record.user.email
end
if leave_record.approved_by.present?
json.approved_by do
json.id leave_record.approved_by.id
json.name leave_record.approved_by.name
json.email leave_record.approved_by.email
end
else
json.approved_by nil
end
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.4.0",
"version": "4.5.1",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -0,0 +1,63 @@
require 'rails_helper'
RSpec.describe 'Assignment Policy Inboxes API', type: :request do
let(:account) { create(:account) }
let(:assignment_policy) { create(:assignment_policy, account: account) }
describe 'GET /api/v1/accounts/{account_id}/assignment_policies/{assignment_policy_id}/inboxes' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}/inboxes"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin' do
let(:admin) { create(:user, account: account, role: :administrator) }
context 'when assignment policy has associated inboxes' do
before do
inbox1 = create(:inbox, account: account)
inbox2 = create(:inbox, account: account)
create(:inbox_assignment_policy, inbox: inbox1, assignment_policy: assignment_policy)
create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: assignment_policy)
end
it 'returns all inboxes associated with the assignment policy' do
get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}/inboxes",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['inboxes']).to be_an(Array)
expect(json_response['inboxes'].length).to eq(2)
end
end
context 'when assignment policy has no associated inboxes' do
it 'returns empty array' do
get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}/inboxes",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['inboxes']).to eq([])
end
end
end
context 'when it is an agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}/inboxes",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
end
@@ -0,0 +1,326 @@
require 'rails_helper'
RSpec.describe 'Assignment Policies API', type: :request do
let(:account) { create(:account) }
describe 'GET /api/v1/accounts/{account.id}/assignment_policies' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/assignment_policies"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin' do
let(:admin) { create(:user, account: account, role: :administrator) }
before do
create_list(:assignment_policy, 3, account: account)
end
it 'returns all assignment policies for the account' do
get "/api/v1/accounts/#{account.id}/assignment_policies",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response.length).to eq(3)
expect(json_response.first.keys).to include('id', 'name', 'description')
end
end
context 'when it is an agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/assignment_policies",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/assignment_policies/:id' do
let(:assignment_policy) { create(:assignment_policy, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin' do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'returns the assignment policy' do
get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['id']).to eq(assignment_policy.id)
expect(json_response['name']).to eq(assignment_policy.name)
end
it 'returns not found for non-existent policy' do
get "/api/v1/accounts/#{account.id}/assignment_policies/999999",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when it is an agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/assignment_policies' do
let(:valid_params) do
{
assignment_policy: {
name: 'New Assignment Policy',
description: 'Policy for new team',
conversation_priority: 'longest_waiting',
fair_distribution_limit: 15,
enabled: true
}
}
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/assignment_policies", params: valid_params
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin' do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'creates a new assignment policy' do
expect do
post "/api/v1/accounts/#{account.id}/assignment_policies",
headers: admin.create_new_auth_token,
params: valid_params,
as: :json
end.to change(AssignmentPolicy, :count).by(1)
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['name']).to eq('New Assignment Policy')
expect(json_response['conversation_priority']).to eq('longest_waiting')
end
it 'creates policy with minimal required params' do
minimal_params = { assignment_policy: { name: 'Minimal Policy' } }
expect do
post "/api/v1/accounts/#{account.id}/assignment_policies",
headers: admin.create_new_auth_token,
params: minimal_params,
as: :json
end.to change(AssignmentPolicy, :count).by(1)
expect(response).to have_http_status(:success)
end
it 'prevents duplicate policy names within account' do
create(:assignment_policy, account: account, name: 'Duplicate Policy')
duplicate_params = { assignment_policy: { name: 'Duplicate Policy' } }
expect do
post "/api/v1/accounts/#{account.id}/assignment_policies",
headers: admin.create_new_auth_token,
params: duplicate_params,
as: :json
end.not_to change(AssignmentPolicy, :count)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'validates required fields' do
invalid_params = { assignment_policy: { name: '' } }
post "/api/v1/accounts/#{account.id}/assignment_policies",
headers: admin.create_new_auth_token,
params: invalid_params,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
context 'when it is an agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/assignment_policies",
headers: agent.create_new_auth_token,
params: valid_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'PUT /api/v1/accounts/{account.id}/assignment_policies/:id' do
let(:assignment_policy) { create(:assignment_policy, account: account, name: 'Original Policy') }
let(:update_params) do
{
assignment_policy: {
name: 'Updated Policy',
description: 'Updated description',
fair_distribution_limit: 20
}
}
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
params: update_params
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin' do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'updates the assignment policy' do
put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
headers: admin.create_new_auth_token,
params: update_params,
as: :json
expect(response).to have_http_status(:success)
assignment_policy.reload
expect(assignment_policy.name).to eq('Updated Policy')
expect(assignment_policy.fair_distribution_limit).to eq(20)
end
it 'allows partial updates' do
partial_params = { assignment_policy: { enabled: false } }
put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
headers: admin.create_new_auth_token,
params: partial_params,
as: :json
expect(response).to have_http_status(:success)
expect(assignment_policy.reload.enabled).to be(false)
expect(assignment_policy.name).to eq('Original Policy') # unchanged
end
it 'prevents duplicate names during update' do
create(:assignment_policy, account: account, name: 'Existing Policy')
duplicate_params = { assignment_policy: { name: 'Existing Policy' } }
put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
headers: admin.create_new_auth_token,
params: duplicate_params,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns not found for non-existent policy' do
put "/api/v1/accounts/#{account.id}/assignment_policies/999999",
headers: admin.create_new_auth_token,
params: update_params,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when it is an agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized' do
put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
headers: agent.create_new_auth_token,
params: update_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/assignment_policies/:id' do
let(:assignment_policy) { create(:assignment_policy, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin' do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'deletes the assignment policy' do
assignment_policy # create it first
expect do
delete "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
headers: admin.create_new_auth_token,
as: :json
end.to change(AssignmentPolicy, :count).by(-1)
expect(response).to have_http_status(:ok)
end
it 'cascades deletion to associated inbox assignment policies' do
inbox = create(:inbox, account: account)
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
expect do
delete "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
headers: admin.create_new_auth_token,
as: :json
end.to change(InboxAssignmentPolicy, :count).by(-1)
expect(response).to have_http_status(:ok)
end
it 'returns not found for non-existent policy' do
delete "/api/v1/accounts/#{account.id}/assignment_policies/999999",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when it is an agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
end
@@ -0,0 +1,195 @@
require 'rails_helper'
RSpec.describe 'Inbox Assignment Policies API', type: :request do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:assignment_policy) { create(:assignment_policy, account: account) }
describe 'GET /api/v1/accounts/{account_id}/inboxes/{inbox_id}/assignment_policy' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin' do
let(:admin) { create(:user, account: account, role: :administrator) }
context 'when inbox has an assignment policy' do
before do
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
end
it 'returns the assignment policy for the inbox' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['id']).to eq(assignment_policy.id)
expect(json_response['name']).to eq(assignment_policy.name)
end
end
context 'when inbox has no assignment policy' do
it 'returns not found' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
end
context 'when it is an agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST /api/v1/accounts/{account_id}/inboxes/{inbox_id}/assignment_policy' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
params: { assignment_policy_id: assignment_policy.id }
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin' do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'assigns a policy to the inbox' do
expect do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
params: { assignment_policy_id: assignment_policy.id },
headers: admin.create_new_auth_token,
as: :json
end.to change(InboxAssignmentPolicy, :count).by(1)
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['id']).to eq(assignment_policy.id)
end
it 'replaces existing assignment policy for inbox' do
other_policy = create(:assignment_policy, account: account)
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: other_policy)
expect do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
params: { assignment_policy_id: assignment_policy.id },
headers: admin.create_new_auth_token,
as: :json
end.not_to change(InboxAssignmentPolicy, :count)
expect(response).to have_http_status(:success)
expect(inbox.reload.inbox_assignment_policy.assignment_policy).to eq(assignment_policy)
end
it 'returns not found for invalid assignment policy' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
params: { assignment_policy_id: 999_999 },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
it 'returns not found for invalid inbox' do
post "/api/v1/accounts/#{account.id}/inboxes/999999/assignment_policy",
params: { assignment_policy_id: assignment_policy.id },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when it is an agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
params: { assignment_policy_id: assignment_policy.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'DELETE /api/v1/accounts/{account_id}/inboxes/{inbox_id}/assignment_policy' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin' do
let(:admin) { create(:user, account: account, role: :administrator) }
context 'when inbox has an assignment policy' do
before do
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
end
it 'removes the assignment policy from inbox' do
expect do
delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
headers: admin.create_new_auth_token,
as: :json
end.to change(InboxAssignmentPolicy, :count).by(-1)
expect(response).to have_http_status(:success)
expect(inbox.reload.inbox_assignment_policy).to be_nil
end
end
context 'when inbox has no assignment policy' do
it 'returns error' do
expect do
delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
headers: admin.create_new_auth_token,
as: :json
end.not_to change(InboxAssignmentPolicy, :count)
expect(response).to have_http_status(:not_found)
end
end
it 'returns not found for invalid inbox' do
delete "/api/v1/accounts/#{account.id}/inboxes/999999/assignment_policy",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when it is an agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
end
@@ -0,0 +1,152 @@
require 'rails_helper'
RSpec.describe 'LeaveRecords 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(:other_agent) { create(:user, account: account, role: :agent) }
describe 'GET /api/v1/accounts/:account_id/leave_records' do
before do
create(:leave_record, account: account, user: agent)
create(:leave_record, account: account, user: other_agent)
end
it 'allows admins to see all leave records' do
get "/api/v1/accounts/#{account.id}/leave_records",
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(json_response.length).to eq(2)
end
it 'allows agents to see only their own leave records' do
get "/api/v1/accounts/#{account.id}/leave_records",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:success)
expect(json_response.length).to eq(1)
expect(json_response.first['user']['id']).to eq(agent.id)
end
end
describe 'POST /api/v1/accounts/:account_id/leave_records' do
let(:valid_params) do
{
leave_record: {
start_date: 1.week.from_now.to_date,
end_date: 2.weeks.from_now.to_date,
leave_type: 'annual',
reason: 'Family vacation'
}
}
end
it 'creates a leave record for the current user' do
expect do
post "/api/v1/accounts/#{account.id}/leave_records",
params: valid_params,
headers: agent.create_new_auth_token
end.to change(LeaveRecord, :count).by(1)
expect(response).to have_http_status(:success)
expect(json_response['user']['id']).to eq(agent.id)
expect(json_response['status']).to eq('pending')
end
it 'fails with invalid params' do
post "/api/v1/accounts/#{account.id}/leave_records",
params: { leave_record: { start_date: nil } },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
end
end
describe 'PATCH /api/v1/accounts/:account_id/leave_records/:id' do
let(:leave_record) { create(:leave_record, account: account, user: agent, status: :pending) }
let(:update_params) { { leave_record: { reason: 'Updated reason' } } }
it 'allows updating pending leave records' do
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}",
params: update_params,
headers: agent.create_new_auth_token
expect(response).to have_http_status(:success)
expect(json_response['reason']).to eq('Updated reason')
end
it 'prevents updating approved leave records' do
leave_record.update!(status: :approved)
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}",
params: update_params,
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response['error']).to eq(I18n.t('errors.leave_records.cannot_update_non_pending'))
end
end
describe 'DELETE /api/v1/accounts/:account_id/leave_records/:id' do
let!(:leave_record) { create(:leave_record, account: account, user: agent, status: :pending) }
it 'allows deleting cancellable leave records' do
expect do
delete "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}",
headers: agent.create_new_auth_token
end.to change(LeaveRecord, :count).by(-1)
expect(response).to have_http_status(:ok)
end
it 'prevents deleting non-cancellable leave records' do
leave_record.update!(status: :approved, start_date: Date.current, end_date: Date.current + 2.days)
delete "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response['error']).to eq(I18n.t('errors.leave_records.cannot_delete'))
end
end
describe 'PATCH /api/v1/accounts/:account_id/leave_records/:id/approve' do
let(:leave_record) { create(:leave_record, account: account, user: agent, status: :pending) }
it 'allows admins to approve leave records' do
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}/approve",
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(json_response['status']).to eq('approved')
expect(json_response['approved_by']['id']).to eq(admin.id)
end
it 'denies agents from approving leave records' do
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}/approve",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
end
describe 'PATCH /api/v1/accounts/:account_id/leave_records/:id/reject' do
let(:leave_record) { create(:leave_record, account: account, user: agent, status: :pending) }
it 'allows admins to reject leave records' do
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}/reject",
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(json_response['status']).to eq('rejected')
expect(json_response['approved_by']['id']).to eq(admin.id)
end
end
private
def json_response
JSON.parse(response.body)
end
end
@@ -0,0 +1,18 @@
require 'rails_helper'
RSpec.describe AssignmentPolicy do
let(:account) { create(:account) }
describe 'enum values' do
let(:assignment_policy) { create(:assignment_policy, account: account) }
describe 'assignment_order' do
it 'can be set to balanced' do
assignment_policy.update!(assignment_order: :balanced)
expect(assignment_policy.assignment_order).to eq('balanced')
expect(assignment_policy.round_robin?).to be false
expect(assignment_policy.balanced?).to be true
end
end
end
end
+130
View File
@@ -0,0 +1,130 @@
require 'rails_helper'
RSpec.describe LeaveRecord, type: :model do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:admin) { create(:user, account: account, role: :administrator) }
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:approved_by).class_name('User').optional }
end
describe 'validations' do
subject { build(:leave_record, account: account, user: user) }
it { is_expected.to validate_presence_of(:start_date) }
it { is_expected.to validate_presence_of(:end_date) }
it { is_expected.to validate_presence_of(:leave_type) }
it { is_expected.to validate_presence_of(:status) }
it 'validates end_date is after start_date' do
leave = build(:leave_record, account: account, user: user,
start_date: Date.current + 2.days,
end_date: Date.current)
expect(leave).not_to be_valid
expect(leave.errors[:end_date]).to include('must be after start date')
end
it 'validates future dates for pending leaves' do
leave = build(:leave_record, account: account, user: user,
status: :pending,
start_date: Date.current)
expect(leave).not_to be_valid
expect(leave.errors[:start_date]).to include('must be in the future')
end
it 'validates approved_by is admin when present' do
agent = create(:user, account: account, role: :agent)
leave = build(:leave_record, account: account, user: user,
approved_by: agent,
status: :approved)
expect(leave).not_to be_valid
expect(leave.errors[:approved_by]).to include('must be an administrator')
end
end
describe '#duration_in_days' do
it 'calculates correct duration' do
leave = build(:leave_record,
start_date: Date.current + 1.day,
end_date: Date.current + 5.days)
expect(leave.duration_in_days).to eq(5)
end
end
describe '#can_be_cancelled?' do
it 'returns true for pending leaves' do
leave = build(:leave_record, status: :pending)
expect(leave.can_be_cancelled?).to be true
end
it 'returns true for approved future leaves' do
leave = build(:leave_record, status: :approved,
start_date: Date.current + 1.day)
expect(leave.can_be_cancelled?).to be true
end
it 'returns false for approved ongoing leaves' do
leave = build(:leave_record, status: :approved,
start_date: Date.current)
expect(leave.can_be_cancelled?).to be false
end
it 'returns false for rejected leaves' do
leave = build(:leave_record, status: :rejected)
expect(leave.can_be_cancelled?).to be false
end
end
describe '#approve!' do
let(:leave) { create(:leave_record, account: account, user: user, status: :pending) }
it 'approves the leave and sets approved_by' do
freeze_time do
leave.approve!(admin)
expect(leave.status).to eq('approved')
expect(leave.approved_by).to eq(admin)
expect(leave.approved_at).to eq(Time.current)
end
end
end
describe '#reject!' do
let(:leave) { create(:leave_record, account: account, user: user, status: :pending) }
it 'rejects the leave and sets approved_by' do
freeze_time do
leave.reject!(admin)
expect(leave.status).to eq('rejected')
expect(leave.approved_by).to eq(admin)
expect(leave.approved_at).to eq(Time.current)
end
end
end
describe '#overlaps_with?' do
let(:leave1) do
build(:leave_record,
start_date: Date.current + 5.days,
end_date: Date.current + 10.days)
end
it 'detects overlapping leaves' do
leave2 = build(:leave_record,
start_date: Date.current + 7.days,
end_date: Date.current + 12.days)
expect(leave1.overlaps_with?(leave2)).to be true
end
it 'returns false for non-overlapping leaves' do
leave2 = build(:leave_record,
start_date: Date.current + 11.days,
end_date: Date.current + 15.days)
expect(leave1.overlaps_with?(leave2)).to be false
end
end
end
@@ -0,0 +1,75 @@
require 'rails_helper'
RSpec.describe LeaveRecordPolicy, type: :policy do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:leave_record) { create(:leave_record, account: account, user: agent) }
describe 'permissions' do
context 'when user is administrator' do
let(:policy) { described_class.new(pundit_context(admin), leave_record) }
it 'permits all actions' do
expect(policy.index?).to be true
expect(policy.show?).to be true
expect(policy.create?).to be true
expect(policy.update?).to be true
expect(policy.destroy?).to be true
expect(policy.approve?).to be true
expect(policy.reject?).to be true
end
end
context 'when user is agent' do
let(:policy) { described_class.new(pundit_context(agent), leave_record) }
it 'permits basic actions' do
expect(policy.index?).to be true
expect(policy.show?).to be true
expect(policy.create?).to be true
expect(policy.update?).to be true
expect(policy.destroy?).to be true
end
it 'denies approval actions' do
expect(policy.approve?).to be false
expect(policy.reject?).to be false
end
end
end
describe 'scope' do
let!(:agent_leave) { create(:leave_record, account: account, user: agent) }
let!(:other_agent) { create(:user, account: account, role: :agent) }
let!(:other_leave) { create(:leave_record, account: account, user: other_agent) }
it 'returns all leaves for administrators' do
scope = described_class::Scope.new(pundit_context(admin), LeaveRecord).resolve
expect(scope).to include(agent_leave, other_leave)
expect(scope.count).to eq(2)
end
it 'returns only own leaves for agents' do
scope = described_class::Scope.new(pundit_context(agent), LeaveRecord).resolve
expect(scope).to include(agent_leave)
expect(scope).not_to include(other_leave)
expect(scope.count).to eq(1)
end
it 'includes associations for performance' do
scope = described_class::Scope.new(pundit_context(admin), LeaveRecord).resolve
expect(scope.includes_values).to include(:user, :approved_by)
end
end
private
def pundit_context(user)
{
user: user,
account: account,
account_user: user.account_users.find_by(account: account)
}
end
end
+3 -25
View File
@@ -1,34 +1,12 @@
# frozen_string_literal: true
FactoryBot.define do
factory :assignment_policy do
account
sequence(:name) { |n| "Assignment Policy #{n}" }
description { 'Test assignment policy' }
assignment_order { :round_robin }
conversation_priority { :earliest_created }
description { 'Test assignment policy description' }
assignment_order { 0 }
conversation_priority { 0 }
fair_distribution_limit { 10 }
fair_distribution_window { 3600 }
enabled { true }
trait :balanced do
assignment_order { :balanced }
end
trait :disabled do
enabled { false }
end
trait :longest_waiting do
conversation_priority { :longest_waiting }
end
trait :with_high_limit do
fair_distribution_limit { 50 }
end
trait :with_short_window do
fair_distribution_window { 300 } # 5 minutes
end
end
end
@@ -1,13 +1,6 @@
# frozen_string_literal: true
FactoryBot.define do
factory :inbox_assignment_policy do
inbox
assignment_policy
# Ensure inbox and policy belong to same account
after(:build) do |inbox_policy|
inbox_policy.assignment_policy.account = inbox_policy.inbox.account if inbox_policy.inbox && inbox_policy.assignment_policy
end
end
end
+44
View File
@@ -0,0 +1,44 @@
FactoryBot.define do
factory :leave_record do
account
user
start_date { 1.week.from_now.to_date }
end_date { 2.weeks.from_now.to_date }
leave_type { :annual }
status { :pending }
reason { 'Annual vacation leave' }
trait :sick do
leave_type { :sick }
reason { 'Sick leave for medical treatment' }
end
trait :emergency do
leave_type { :emergency }
reason { 'Emergency family matter' }
end
trait :other do
leave_type { :other }
reason { 'Other type of leave' }
end
trait :approved do
status { :approved }
approved_by { association(:user) }
approved_at { 1.day.ago }
end
trait :rejected do
status { :rejected }
approved_by { association(:user) }
approved_at { 1.day.ago }
end
trait :past_dates do
start_date { 2.weeks.ago.to_date }
end_date { 1.week.ago.to_date }
status { :approved } # Past dates should only be used with approved status
end
end
end
@@ -1,196 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::AssignmentJob, type: :job do
before do
# Mock GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
end
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
describe '#perform' do
context 'with conversation_id' do
it 'assigns a single conversation' do
service = instance_double(AssignmentV2::AssignmentService)
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_for_conversation).with(conversation)
described_class.new.perform(conversation_id: conversation.id)
end
it 'handles non-existent conversation gracefully' do
expect(AssignmentV2::AssignmentService).not_to receive(:new)
# Should not raise error
expect do
described_class.new.perform(conversation_id: 999_999)
end.not_to raise_error
end
end
context 'with inbox_id' do
let!(:agent) { create(:user, account: account, role: :agent, availability: :online) }
before do
create_list(:conversation, 3, inbox: inbox, assignee: nil)
create(:inbox_member, inbox: inbox, user: agent)
end
it 'assigns multiple conversations for inbox' do
# Mock the feature flag for assignment_v2
allow(inbox.account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
service = instance_double(AssignmentV2::AssignmentService)
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_bulk_assignment).and_return(3)
described_class.new.perform(inbox_id: inbox.id)
end
it 'logs the number of assigned conversations' do
# Mock the feature flag for assignment_v2
allow(inbox.account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
allow(service).to receive(:perform_bulk_assignment).and_return(2)
expect(Rails.logger).to receive(:info).with("AssignmentV2::AssignmentJob: Assigned 2 conversations for inbox #{inbox.id}")
described_class.new.perform(inbox_id: inbox.id)
end
it 'skips assignment when inbox has no policy' do
inbox_assignment_policy.destroy!
expect(AssignmentV2::AssignmentService).not_to receive(:new)
described_class.new.perform(inbox_id: inbox.id)
end
it 'skips assignment when policy is disabled' do
assignment_policy.update!(enabled: false)
expect(AssignmentV2::AssignmentService).not_to receive(:new)
described_class.new.perform(inbox_id: inbox.id)
end
it 'handles non-existent inbox gracefully' do
expect(AssignmentV2::AssignmentService).not_to receive(:new)
# Should not raise error
expect do
described_class.new.perform(inbox_id: 999_999)
end.not_to raise_error
end
end
context 'without parameters' do
it 'logs error when no parameters provided' do
expect(Rails.logger).to receive(:error).with('AssignmentV2::AssignmentJob: No inbox_id or conversation_id provided')
described_class.new.perform
end
it 'does not attempt assignment' do
expect(AssignmentV2::AssignmentService).not_to receive(:new)
described_class.new.perform
end
end
context 'with both parameters' do
it 'prioritizes conversation_id over inbox_id' do
service = instance_double(AssignmentV2::AssignmentService)
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_for_conversation).with(conversation)
expect(service).not_to receive(:perform_bulk_assignment)
described_class.new.perform(conversation_id: conversation.id, inbox_id: inbox.id)
end
end
end
describe 'job configuration' do
it 'uses the low queue' do
expect(described_class.new.queue_name).to eq('low')
end
end
describe 'error handling' do
context 'when assignment service raises error' do
it 'propagates the error for retry' do
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
allow(service).to receive(:perform_for_conversation).and_raise(StandardError, 'Assignment failed')
expect do
described_class.new.perform(conversation_id: conversation.id)
end.to raise_error(StandardError, 'Assignment failed')
end
end
context 'when database connection fails' do
it 'raises error for retry' do
allow(Conversation).to receive(:find_by).and_raise(ActiveRecord::ConnectionNotEstablished)
expect do
described_class.new.perform(conversation_id: conversation.id)
end.to raise_error(ActiveRecord::ConnectionNotEstablished)
end
end
end
describe 'concurrency and idempotency' do
it 'handles concurrent job execution safely' do
# Create multiple jobs for same inbox
jobs = []
3.times { jobs << described_class.new }
# All should execute without issues
expect do
jobs.each { |job| job.perform(inbox_id: inbox.id) }
end.not_to raise_error
end
it 'is idempotent for conversation assignment' do
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
# First call assigns
expect(service).to receive(:perform_for_conversation).and_return(true)
described_class.new.perform(conversation_id: conversation.id)
# Second call should handle already assigned conversation
expect(service).to receive(:perform_for_conversation).and_return(false)
expect { described_class.new.perform(conversation_id: conversation.id) }.not_to raise_error
end
end
describe 'performance considerations' do
it 'processes large inbox assignments in batches' do
# Create many unassigned conversations
create_list(:conversation, 100, inbox: inbox, assignee: nil)
# Mock the feature flag for assignment_v2
allow(inbox.account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
# Service should be called with default limit
expect(service).to receive(:perform_bulk_assignment).with(no_args).and_return(50)
described_class.new.perform(inbox_id: inbox.id)
end
end
end
+36 -31
View File
@@ -1,11 +1,6 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentPolicy, type: :model do
let(:account) { create(:account) }
let(:assignment_policy) { create(:assignment_policy, account: account) }
RSpec.describe AssignmentPolicy do
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to have_many(:inbox_assignment_policies).dependent(:destroy) }
@@ -13,39 +8,49 @@ RSpec.describe AssignmentPolicy, type: :model do
end
describe 'validations' do
subject { assignment_policy }
subject { build(:assignment_policy) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:account_id) }
it { is_expected.to validate_length_of(:name).is_at_most(255) }
it { is_expected.to validate_length_of(:description).is_at_most(1000) }
it { is_expected.to validate_presence_of(:fair_distribution_limit) }
it { is_expected.to validate_numericality_of(:fair_distribution_limit).is_greater_than(0).is_less_than_or_equal_to(100) }
it { is_expected.to validate_presence_of(:fair_distribution_window) }
it { is_expected.to validate_numericality_of(:fair_distribution_window).is_greater_than(60).is_less_than_or_equal_to(86_400) }
end
describe 'enums' do
it { is_expected.to define_enum_for(:assignment_order).with_values(round_robin: 0) }
it { is_expected.to define_enum_for(:conversation_priority).with_values(earliest_created: 0, longest_waiting: 1) }
describe 'fair distribution validations' do
it 'requires fair_distribution_limit to be greater than 0' do
policy = build(:assignment_policy, fair_distribution_limit: 0)
expect(policy).not_to be_valid
expect(policy.errors[:fair_distribution_limit]).to include('must be greater than 0')
end
it 'requires fair_distribution_window to be greater than 0' do
policy = build(:assignment_policy, fair_distribution_window: -1)
expect(policy).not_to be_valid
expect(policy.errors[:fair_distribution_window]).to include('must be greater than 0')
end
end
describe '#webhook_data' do
it 'returns correct data structure' do
data = assignment_policy.webhook_data
describe 'enum values' do
let(:assignment_policy) { create(:assignment_policy) }
expect(data).to include(
id: assignment_policy.id,
name: assignment_policy.name,
description: assignment_policy.description,
assignment_order: assignment_policy.assignment_order,
conversation_priority: assignment_policy.conversation_priority,
fair_distribution_limit: assignment_policy.fair_distribution_limit,
fair_distribution_window: assignment_policy.fair_distribution_window,
enabled: assignment_policy.enabled
)
describe 'conversation_priority' do
it 'can be set to earliest_created' do
assignment_policy.update!(conversation_priority: :earliest_created)
expect(assignment_policy.conversation_priority).to eq('earliest_created')
expect(assignment_policy.earliest_created?).to be true
end
it 'can be set to longest_waiting' do
assignment_policy.update!(conversation_priority: :longest_waiting)
expect(assignment_policy.conversation_priority).to eq('longest_waiting')
expect(assignment_policy.longest_waiting?).to be true
end
end
describe 'assignment_order' do
it 'can be set to round_robin' do
assignment_policy.update!(assignment_order: :round_robin)
expect(assignment_policy.assignment_order).to eq('round_robin')
expect(assignment_policy.round_robin?).to be true
end
end
end
end
-116
View File
@@ -1,116 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe InboxAssignmentPolicy, type: :model do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:assignment_policy) { create(:assignment_policy, account: account) }
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
describe 'associations' do
it { is_expected.to belong_to(:inbox) }
it { is_expected.to belong_to(:assignment_policy) }
end
describe 'validations' do
subject { inbox_assignment_policy }
it { is_expected.to validate_uniqueness_of(:inbox_id) }
context 'with inbox and policy from different accounts' do
let(:other_account) { create(:account) }
let(:other_policy) { create(:assignment_policy, account: other_account) }
it 'validates inbox belongs to same account as policy' do
# Build without the factory callback that sets the accounts to be the same
invalid_policy = described_class.new(inbox: inbox, assignment_policy: other_policy)
expect(invalid_policy).not_to be_valid
expect(invalid_policy.errors[:inbox]).to include('must belong to the same account as the assignment policy')
end
end
end
describe 'delegations' do
it 'delegates account to inbox' do
expect(inbox_assignment_policy.account).to eq(account)
end
it 'delegates policy attributes' do
expect(inbox_assignment_policy.policy_name).to eq(assignment_policy.name)
expect(inbox_assignment_policy.policy_description).to eq(assignment_policy.description)
expect(inbox_assignment_policy.policy_assignment_order).to eq(assignment_policy.assignment_order)
expect(inbox_assignment_policy.policy_conversation_priority).to eq(assignment_policy.conversation_priority)
expect(inbox_assignment_policy.policy_fair_distribution_limit).to eq(assignment_policy.fair_distribution_limit)
expect(inbox_assignment_policy.policy_fair_distribution_window).to eq(assignment_policy.fair_distribution_window)
expect(inbox_assignment_policy.policy_enabled?).to eq(assignment_policy.enabled?)
end
end
describe '#webhook_data' do
it 'returns correct data structure' do
data = inbox_assignment_policy.webhook_data
expect(data).to include(
id: inbox_assignment_policy.id,
inbox_id: inbox.id,
assignment_policy_id: assignment_policy.id
)
expect(data[:policy]).to eq(assignment_policy.webhook_data)
end
end
describe 'business logic constraints' do
it 'prevents multiple policies per inbox' do
# Ensure first policy exists
inbox_assignment_policy
policy2 = create(:assignment_policy, account: account)
# Try to create a second policy for the same inbox
duplicate_policy = described_class.new(inbox: inbox, assignment_policy: policy2)
expect(duplicate_policy).not_to be_valid
expect(duplicate_policy.errors[:inbox_id]).to include('has already been taken')
end
it 'allows reassigning to different policy' do
policy2 = create(:assignment_policy, account: account)
expect do
inbox_assignment_policy.update!(assignment_policy: policy2)
end.not_to raise_error
expect(inbox_assignment_policy.reload.assignment_policy).to eq(policy2)
end
end
describe 'edge cases' do
it 'handles nil associations gracefully' do
# Build without saving to test nil handling
policy = build(:inbox_assignment_policy, inbox: nil, assignment_policy: nil)
expect { policy.valid? }.not_to raise_error
expect(policy).not_to be_valid
end
it 'handles policy deletion cascade' do
inbox_policy_id = inbox_assignment_policy.id
# Deleting policy should delete inbox assignment
assignment_policy.destroy!
expect(described_class.find_by(id: inbox_policy_id)).to be_nil
end
it 'handles inbox deletion cascade' do
inbox_policy_id = inbox_assignment_policy.id
# Deleting inbox should delete inbox assignment
inbox.destroy!
expect(described_class.find_by(id: inbox_policy_id)).to be_nil
end
end
end
@@ -1,329 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::AssignmentService do
before do
# Mock the GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
# Define the constant if not already defined
stub_const('ASSIGNEE_CHANGED', 'assignee.changed') unless defined?(ASSIGNEE_CHANGED)
create(:inbox_member, inbox: inbox, user: agent1)
create(:inbox_member, inbox: inbox, user: agent2)
create(:inbox_member, inbox: inbox, user: agent3)
# Mock available agents to return inbox members
online_members = InboxMember.joins(:user).where(inbox: inbox, user: [agent1, agent2])
allow(inbox).to receive(:available_agents).and_return(online_members)
end
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
let(:service) { described_class.new(inbox: inbox) }
# Create agents
let!(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
let!(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
let!(:agent3) { create(:user, account: account, role: :agent, availability: :offline) }
# Make agents members of inbox
describe '#perform_for_conversation' do
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
context 'when policy is enabled' do
before do
# Mock the selector to return an agent
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
end
it 'assigns conversation to an available agent' do
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).to eq(agent1)
end
it 'dispatches assignment event' do
# The dispatcher is called from the assignment service and also from conversation model
allow(Rails.configuration.dispatcher).to receive(:dispatch)
service.perform_for_conversation(conversation)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'assignee.changed',
anything,
hash_including(conversation: conversation, user: agent1)
).at_least(:once)
end
it 'returns false when no agents are available' do
allow(inbox).to receive(:available_agents).and_return(InboxMember.none)
allow(Rails.logger).to receive(:warn)
expect(service.perform_for_conversation(conversation)).to be false
expect(conversation.reload.assignee).to be_nil
end
end
context 'when policy is disabled' do
before { assignment_policy.update!(enabled: false) }
it 'does not assign conversation' do
expect(service.perform_for_conversation(conversation)).to be false
expect(conversation.reload.assignee).to be_nil
end
end
context 'when conversation is already assigned' do
before { conversation.update!(assignee: agent1) }
it 'does not reassign conversation' do
expect(service.perform_for_conversation(conversation)).to be false
expect(conversation.reload.assignee).to eq(agent1)
end
end
context 'with round robin assignment' do
before do
assignment_policy.update!(assignment_order: :round_robin)
# Mock round robin selector to return agents in rotation
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
agent_index = 0
allow(selector).to receive(:select_agent) do
agent = [agent1, agent2][agent_index % 2]
agent_index += 1
agent
end
end
it 'assigns agents in rotation' do
conversations = create_list(:conversation, 4, inbox: inbox, assignee: nil)
assignments = conversations.map do |conv|
service.perform_for_conversation(conv)
conv.reload.assignee
end
# Should rotate between available agents
expect(assignments[0]).to eq(agent1)
expect(assignments[1]).to eq(agent2)
expect(assignments[2]).to eq(agent1) # Back to first agent
expect(assignments[3]).to eq(agent2) # Back to second agent
end
end
context 'with balanced assignment' do
before do
# For now, just use round robin since balanced is enterprise only
# The test is verifying the service works, not the specific algorithm
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent2)
end
it 'assigns conversations successfully' do
# Create existing assignments
create_list(:conversation, 3, inbox: inbox, assignee: agent1, status: :open)
create(:conversation, inbox: inbox, assignee: agent2, status: :open)
new_conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(new_conversation)).to be true
expect(new_conversation.reload.assignee).to eq(agent2)
end
it 'handles different conversation statuses' do
# Create resolved conversations (should not count)
create_list(:conversation, 5, inbox: inbox, assignee: agent1, status: :resolved)
# Create open conversation
create(:conversation, inbox: inbox, assignee: agent2, status: :open)
new_conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(new_conversation)).to be true
expect(new_conversation.reload.assignee).to eq(agent2) # Selected by mock
end
end
context 'when error occurs' do
before do
# Mock the selector to return an agent
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
end
it 'returns false and logs error on assignment failure' do
allow(conversation).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new(conversation))
expect(Rails.logger).to receive(:error).with(/Failed to assign conversation/)
expect(service.perform_for_conversation(conversation)).to be false
end
end
end
describe '#perform_bulk_assignment' do
before do
create_list(:conversation, 5, inbox: inbox, assignee: nil, status: :open)
# Mock the selector to return agents
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
call_count = 0
allow(selector).to receive(:select_agent) do
call_count += 1
call_count.odd? ? agent1 : agent2
end
end
context 'when policy is enabled' do
it 'assigns multiple conversations' do
assigned_count = service.perform_bulk_assignment(limit: 3)
expect(assigned_count).to eq(3)
expect(inbox.conversations.unassigned.count).to eq(2)
end
it 'respects conversation priority order' do
# Clear existing conversations first
Conversation.destroy_all
# Create conversations with different timestamps
old_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, created_at: 1.hour.ago)
new_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, created_at: 1.minute.ago)
assignment_policy.update!(conversation_priority: :earliest_created)
# Re-create service after policy change
service_with_priority = described_class.new(inbox: inbox)
service_with_priority.perform_bulk_assignment(limit: 1)
expect(old_conversation.reload.assignee).not_to be_nil
expect(new_conversation.reload.assignee).to be_nil
end
it 'handles longest_waiting priority' do
# Clear existing conversations first
Conversation.destroy_all
# Create conversations with different last activity
inactive_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, last_activity_at: 2.hours.ago)
active_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, last_activity_at: 5.minutes.ago)
assignment_policy.update!(conversation_priority: :longest_waiting)
# Re-create service after policy change
service_with_priority = described_class.new(inbox: inbox)
service_with_priority.perform_bulk_assignment(limit: 1)
expect(inactive_conversation.reload.assignee).not_to be_nil
expect(active_conversation.reload.assignee).to be_nil
end
it 'returns 0 when no conversations to assign' do
Conversation.find_each { |c| c.update!(assignee_id: agent1.id) }
expect(service.perform_bulk_assignment).to eq(0)
end
end
context 'when policy is disabled' do
before { assignment_policy.update!(enabled: false) }
it 'does not assign any conversations' do
expect(service.perform_bulk_assignment).to eq(0)
expect(inbox.conversations.unassigned.count).to eq(5)
end
end
end
describe 'enterprise capacity features' do
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
before do
# Mock enterprise availability
stub_const('Enterprise', Module.new)
stub_const('Enterprise::AssignmentV2::CapacityManager', Class.new)
# Mock the selector to return agent1
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
end
it 'uses round robin when enterprise features are available' do
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).to eq(agent1)
end
it 'handles absence of enterprise features gracefully' do
# Remove enterprise constant
hide_const('Enterprise')
# Service should still work with round robin
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).to eq(agent1)
end
end
describe 'cache management' do
it 'uses cache for round robin state' do
assignment_policy.update!(assignment_order: :round_robin)
# Mock the selector and round robin service
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
# Create and assign conversations
conversation1 = create(:conversation, inbox: inbox, assignee: nil)
service.perform_for_conversation(conversation1)
conversation2 = create(:conversation, inbox: inbox, assignee: nil)
service.perform_for_conversation(conversation2)
# Just verify assignments worked
expect(conversation1.reload.assignee).to eq(agent1)
expect(conversation2.reload.assignee).to eq(agent1)
end
end
describe 'edge cases' do
it 'handles inbox without policy gracefully' do
inbox_assignment_policy.destroy!
conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(conversation)).to be false
end
it 'handles empty agent list' do
allow(inbox).to receive(:available_agents).and_return(InboxMember.none)
conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(conversation)).to be false
end
it 'filters out agents without inbox membership' do
non_member_agent = create(:user, account: account, role: :agent, availability: :online)
conversation = create(:conversation, inbox: inbox, assignee: nil)
# Mock selector to return agent1 (who is a member)
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).not_to eq(non_member_agent)
expect(conversation.reload.assignee).to eq(agent1)
end
end
end
@@ -1,328 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::RateLimiter, type: :service do
before do
# Mock GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
# Ensure inbox_assignment_policy exists so the inbox has a policy
inbox_assignment_policy
end
let(:account) { create(:account) }
let(:policy) { create(:assignment_policy, account: account, fair_distribution_limit: 5, fair_distribution_window: 3600) }
let(:agent) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: policy) }
let(:rate_limiter) { described_class.new(inbox: inbox, user: agent) }
describe '#initialize' do
it 'sets up rate limiter with inbox and user' do
expect(rate_limiter.instance_variable_get(:@inbox)).to eq(inbox)
expect(rate_limiter.instance_variable_get(:@user)).to eq(agent)
end
end
describe '#within_limits?' do
context 'when agent has no assignments in current window' do
it 'returns true' do
expect(rate_limiter.within_limits?).to be true
end
end
context 'when agent is below limit' do
before do
# Create 3 conversations assigned to agent in current window
3.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
end
it 'returns true' do
expect(rate_limiter.within_limits?).to be true
end
end
context 'when agent reaches limit' do
before do
# Create 5 conversations assigned to agent (at the limit)
5.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
end
it 'returns false' do
expect(rate_limiter.within_limits?).to be false
end
end
context 'when agent exceeds limit' do
before do
# Create 6 conversations assigned to agent (exceeding the limit)
6.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
end
it 'returns false' do
expect(rate_limiter.within_limits?).to be false
end
end
context 'when assignments are from different inbox' do
let(:other_inbox) { create(:inbox, account: account) }
before do
# Create assignments in different inbox
5.times do
create(:conversation, inbox: other_inbox, assignee: agent, updated_at: Time.current)
end
# Create assignments in current inbox
2.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
end
it 'only counts assignments from current inbox' do
expect(rate_limiter.within_limits?).to be true
end
end
context 'when assignments are outside time window' do
before do
# Create old assignments outside the window
5.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: 2.hours.ago)
end
# Create recent assignments within the window
2.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
end
it 'only counts assignments within time window' do
expect(rate_limiter.within_limits?).to be true
end
end
end
describe '#status' do
it 'returns correct status for agent with no assignments' do
status = rate_limiter.status
expect(status[:current_count]).to eq(0)
expect(status[:within_limits]).to be true
expect(status[:limit]).to eq(5)
end
it 'returns correct count after assignments' do
3.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
status = rate_limiter.status
expect(status[:current_count]).to eq(3)
expect(status[:within_limits]).to be true
end
it 'includes reset_at time' do
status = rate_limiter.status
expect(status[:reset_at]).to be_a(Time)
expect(status[:reset_at]).to be > Time.current
end
context 'when policy is disabled' do
before do
policy.update!(enabled: false)
end
it 'returns unlimited status' do
status = rate_limiter.status
expect(status[:within_limits]).to be true
expect(status[:current_count]).to eq(0)
expect(status[:limit]).to eq(Float::INFINITY)
expect(status[:reset_at]).to be_nil
end
end
context 'when no policy exists' do
let(:inbox_without_policy) { create(:inbox, account: account) }
let(:rate_limiter_no_policy) { described_class.new(inbox: inbox_without_policy, user: agent) }
it 'returns unlimited status' do
status = rate_limiter_no_policy.status
expect(status[:within_limits]).to be true
expect(status[:current_count]).to eq(0)
expect(status[:limit]).to eq(Float::INFINITY)
expect(status[:reset_at]).to be_nil
end
end
end
describe 'remaining assignments' do
it 'returns full limit when no assignments made' do
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(5)
end
it 'returns correct remaining count' do
2.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(3)
end
it 'returns 0 when limit reached' do
5.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(0)
end
it 'returns negative when limit exceeded' do
6.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(-1)
end
end
describe 'assignment capacity checks' do
it 'returns true when agent has remaining capacity' do
2.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
expect(rate_limiter.within_limits?).to be true
end
it 'returns false when agent has no capacity' do
5.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
expect(rate_limiter.within_limits?).to be false
end
it 'correctly tracks multiple assignments' do
3.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
status = rate_limiter.status
expect(status[:current_count]).to eq(3)
expect(status[:within_limits]).to be true
expect(status[:limit] - status[:current_count]).to eq(2)
end
end
describe 'multiple agents' do
let(:agent2) { create(:user, account: account) }
let(:rate_limiter2) { described_class.new(inbox: inbox, user: agent2) }
before do
2.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
4.times do
create(:conversation, inbox: inbox, assignee: agent2, updated_at: Time.current)
end
end
it 'tracks status independently for each agent' do
status1 = rate_limiter.status
status2 = rate_limiter2.status
expect(status1[:current_count]).to eq(2)
expect(status1[:within_limits]).to be true
expect(status1[:limit] - status1[:current_count]).to eq(3)
expect(status2[:current_count]).to eq(4)
expect(status2[:within_limits]).to be true
expect(status2[:limit] - status2[:current_count]).to eq(1)
end
end
describe 'window timing' do
it 'calculates reset time correctly' do
# Mock current time to make test predictable
travel_to(Time.zone.parse('2024-01-01 10:30:00')) do
status = rate_limiter.status
reset_time = status[:reset_at]
expect(reset_time).to be_a(Time)
expect(reset_time).to be > Time.current
expect(reset_time - Time.current).to be <= 3600
end
end
end
describe 'window boundaries' do
it 'resets count in new window' do
# Set up assignments in current window
2.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
expect(rate_limiter.status[:current_count]).to eq(2)
# Travel to next window (advance by window size)
travel(3601.seconds) do
expect(rate_limiter.status[:current_count]).to eq(0)
expect(rate_limiter.within_limits?).to be true
end
end
it 'does not count assignments from previous window' do
# Create assignments in previous window
travel_to(2.hours.ago) do
3.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
end
# Check current window
expect(rate_limiter.status[:current_count]).to eq(0)
expect(rate_limiter.within_limits?).to be true
end
end
describe 'policy configuration' do
context 'with different fair_distribution_limit' do
let(:policy) { create(:assignment_policy, account: account, fair_distribution_limit: 10, fair_distribution_window: 3600) }
it 'uses policy limit' do
8.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
expect(rate_limiter.within_limits?).to be true
2.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
expect(rate_limiter.within_limits?).to be false
end
end
context 'with different fair_distribution_window' do
let(:policy) { create(:assignment_policy, account: account, fair_distribution_limit: 5, fair_distribution_window: 7200) }
it 'uses policy window' do
# Calculate the current window start based on the 2-hour window
current_time = Time.current
window_start_timestamp = (current_time.to_i / 7200) * 7200
# Create assignments 30 minutes after window start (definitely within window)
travel_to(Time.zone.at(window_start_timestamp + 30.minutes)) do
3.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
end
# These should still count toward the limit
expect(rate_limiter.status[:current_count]).to eq(3)
expect(rate_limiter.within_limits?).to be true
end
end
end
end
@@ -1,145 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::RoundRobinSelector, type: :service do
before do
# Mock GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
create(:inbox_member, inbox: inbox, user: user1)
create(:inbox_member, inbox: inbox, user: user2)
create(:inbox_member, inbox: inbox, user: user3)
end
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:policy) { create(:assignment_policy, account: account) }
let(:user1) { create(:user, account: account, availability: :online) }
let(:user2) { create(:user, account: account, availability: :online) }
let(:user3) { create(:user, account: account, availability: :offline) }
describe '#select_agent' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
let(:available_agents) { InboxMember.where(inbox: inbox, user: [user1, user2]) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
context 'when Redis is available' do
before do
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(user1.id.to_s)
end
it 'returns an online agent' do
result = selector.select_agent(available_agents)
expect(result).to eq(user1)
end
it 'excludes offline agents' do
result = selector.select_agent(available_agents)
expect(result).not_to eq(user3)
end
it 'handles no available agent gracefully' do
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(nil)
result = selector.select_agent(available_agents)
expect(result).to be_nil
end
end
context 'when Redis fails' do
before do
allow(round_robin_service).to receive(:available_agent).and_raise(Redis::CannotConnectError)
end
it 'raises the error' do
expect { selector.select_agent(available_agents) }.to raise_error(Redis::CannotConnectError)
end
end
context 'with empty available agents' do
it 'returns nil when no agents are available' do
result = selector.select_agent(InboxMember.none)
expect(result).to be_nil
end
end
context 'with different user IDs' do
it 'correctly finds the inbox member by user_id' do
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(user2.id.to_s)
result = selector.select_agent(available_agents)
expect(result).to eq(user2)
end
end
end
describe '#add_agent_to_queue' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'delegates to round robin service' do
expect(round_robin_service).to receive(:add_agent_to_queue).with(user1.id)
selector.add_agent_to_queue(user1.id)
end
end
describe '#remove_agent_from_queue' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'delegates to round robin service' do
expect(round_robin_service).to receive(:remove_agent_from_queue).with(user1.id)
selector.remove_agent_from_queue(user1.id)
end
end
describe '#reset_queue' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'delegates to round robin service' do
expect(round_robin_service).to receive(:reset_queue)
selector.reset_queue
end
end
describe 'edge cases' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
let(:available_agents) { InboxMember.where(inbox: inbox, user: [user1, user2]) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'handles invalid user_id from round robin service' do
allow(round_robin_service).to receive(:available_agent).and_return('invalid_id')
result = selector.select_agent(available_agents)
expect(result).to be_nil
end
it 'handles user_id not in available agents' do
other_user = create(:user, account: account)
allow(round_robin_service).to receive(:available_agent).and_return(other_user.id.to_s)
result = selector.select_agent(available_agents)
expect(result).to be_nil
end
end
end