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
86 changed files with 2196 additions and 364 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
@@ -0,0 +1,36 @@
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
end
def show; end
def create
@assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params)
end
def update
@assignment_policy.update!(assignment_policy_params)
end
def destroy
@assignment_policy.destroy!
head :ok
end
private
def fetch_assignment_policy
@assignment_policy = Current.account.assignment_policies.find(params[:id])
end
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled
)
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,
+1
View File
@@ -61,6 +61,7 @@ class Account < ApplicationRecord
has_many :agent_bots, dependent: :destroy_async
has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api'
has_many :articles, dependent: :destroy_async, class_name: '::Article'
has_many :assignment_policies, dependent: :destroy_async
has_many :automation_rules, dependent: :destroy_async
has_many :macros, dependent: :destroy_async
has_many :campaigns, dependent: :destroy_async
+37
View File
@@ -0,0 +1,37 @@
# == Schema Information
#
# Table name: assignment_policies
#
# id :bigint not null, primary key
# assignment_order :integer default(0), not null
# conversation_priority :integer default("earliest_created"), not null
# description :text
# enabled :boolean default(TRUE), not null
# fair_distribution_limit :integer default(100), not null
# fair_distribution_window :integer default(3600), not null
# name :string(255) not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_assignment_policies_on_account_id (account_id)
# index_assignment_policies_on_account_id_and_name (account_id,name) UNIQUE
# index_assignment_policies_on_enabled (enabled)
#
class AssignmentPolicy < ApplicationRecord
belongs_to :account
has_many :inbox_assignment_policies, dependent: :destroy
has_many :inboxes, through: :inbox_assignment_policies
validates :name, presence: true, uniqueness: { scope: :account_id }
validates :fair_distribution_limit, numericality: { greater_than: 0 }
validates :fair_distribution_window, numericality: { greater_than: 0 }
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
enum assignment_order: { round_robin: 0 } unless ChatwootApp.enterprise?
end
AssignmentPolicy.include_mod_with('Concerns::AssignmentPolicy')
+2
View File
@@ -67,6 +67,8 @@ 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
+21
View File
@@ -0,0 +1,21 @@
# == Schema Information
#
# Table name: inbox_assignment_policies
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# assignment_policy_id :bigint not null
# inbox_id :bigint not null
#
# Indexes
#
# index_inbox_assignment_policies_on_assignment_policy_id (assignment_policy_id)
# index_inbox_assignment_policies_on_inbox_id (inbox_id) UNIQUE
#
class InboxAssignmentPolicy < ApplicationRecord
belongs_to :inbox
belongs_to :assignment_policy
validates :inbox_id, uniqueness: true
end
+21
View File
@@ -0,0 +1,21 @@
class AssignmentPolicyPolicy < ApplicationPolicy
def index?
@account_user.administrator?
end
def show?
@account_user.administrator?
end
def create?
@account_user.administrator?
end
def update?
@account_user.administrator?
end
def destroy?
@account_user.administrator?
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?
+4
View File
@@ -191,3 +191,7 @@
display_name: CRM V2
enabled: false
chatwoot_internal: true
- 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'
+15
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]
@@ -217,6 +223,15 @@ Rails.application.routes.draw do
end
end
# Assignment V2 Routes
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], module: :inboxes
end
namespace :twitter do
resource :authorization, only: [:create]
end
@@ -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
@@ -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
@@ -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
+12
View File
@@ -0,0 +1,12 @@
FactoryBot.define do
factory :assignment_policy do
account
sequence(:name) { |n| "Assignment Policy #{n}" }
description { 'Test assignment policy description' }
assignment_order { 0 }
conversation_priority { 0 }
fair_distribution_limit { 10 }
fair_distribution_window { 3600 }
enabled { true }
end
end
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :inbox_assignment_policy do
inbox
assignment_policy
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
+56
View File
@@ -0,0 +1,56 @@
require 'rails_helper'
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) }
it { is_expected.to have_many(:inboxes).through(:inbox_assignment_policies) }
end
describe 'validations' do
subject { build(:assignment_policy) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:account_id) }
end
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 'enum values' do
let(:assignment_policy) { create(:assignment_policy) }
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