Compare commits

..
Author SHA1 Message Date
Pranav f57cb830f4 llm tracer 2025-08-18 20:37:18 -07:00
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
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
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
45 changed files with 1055 additions and 984 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.
+4 -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
@@ -186,6 +186,9 @@ gem 'ruby_llm-schema'
gem 'shopify_api'
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
### Gems required only in specific deployment environments ###
##############################################################
+39 -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)
@@ -550,6 +564,25 @@ GEM
actionpack (>= 4.2)
omniauth (~> 2.0)
openssl (3.2.0)
opentelemetry-api (1.6.0)
opentelemetry-common (0.22.0)
opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.26.3)
google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.2)
opentelemetry-semantic_conventions
opentelemetry-registry (0.4.0)
opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.3.2)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2)
opentelemetry-semantic_conventions
opentelemetry-semantic_conventions (1.11.0)
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.1)
@@ -928,7 +961,7 @@ DEPENDENCIES
commonmarker
csv-safe
database_cleaner
ddtrace
datadog (~> 2.0)
debug (~> 1.8)
devise (>= 4.9.4)
devise-secure_password!
@@ -980,6 +1013,8 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
opentelemetry-exporter-otlp
opentelemetry-sdk
pg
pg_search
pgvector
@@ -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
@@ -4,8 +4,8 @@ export default [
city: 'Los Angeles',
country: 'United States',
description:
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Chatwoot.",
companyName: 'Chatwoot',
"I'm Candice, a developer focusing on building web solutions. Currently, I’m 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',
@@ -1,237 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
selectedTemplate: {
type: Object,
default: null,
},
templateVariables: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
// Template data based on templates.json examples
const templateExamples = {
training_video: {
header: { type: 'VIDEO', content: '🎥 Video Header' },
body: "Hi {{name}}, here's your training video. Please watch by {{date}}.",
variables: { name: 'John', date: ' July 31' },
},
discount_coupon: {
body: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
buttons: [{ type: 'COPY_CODE', text: 'Copy offer code' }],
variables: { discount_percentage: '30' },
},
support_callback: {
body: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
buttons: [{ type: 'PHONE_NUMBER', text: 'Call Support' }],
variables: { name: 'muhsin', ticket_id: '232323' },
},
feedback_request: {
body: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
buttons: [{ type: 'URL', text: 'Leave Feedback' }],
variables: { name: 'muhsin' },
},
order_confirmation: {
header: { type: 'IMAGE', content: '🖼️ Product Image' },
body: 'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
},
technician_visit: {
header: { type: 'TEXT', content: 'Technician visit' },
body: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
buttons: [
{ type: 'QUICK_REPLY', text: 'Confirm' },
{ type: 'QUICK_REPLY', text: 'Reschedule' },
],
variables: {
1: 'John',
2: '123 Maple St',
3: '2025-12-31',
4: '10:00 AM',
5: '2:00 PM',
},
},
};
const getTemplatePreview = () => {
// If no template selected, return null to show empty state
if (!props.selectedTemplate) {
return null;
}
const templateName = props.selectedTemplate.name;
const templateExample = templateExamples[templateName];
if (templateExample) {
let processedBody = templateExample.body;
// Use actual template variables if provided, otherwise fall back to examples
const variablesToUse =
Object.keys(props.templateVariables).length > 0
? props.templateVariables
: templateExample.variables;
// Replace variables with actual or example values
if (variablesToUse) {
Object.entries(variablesToUse).forEach(([key, value]) => {
// Handle both positional ({{1}}) and named ({{name}}) variables
const pattern = `{{${key}}}`;
processedBody = processedBody.replace(
new RegExp(pattern.replace(/[{}]/g, '\\$&'), 'g'),
value || `{{${key}}}`
);
});
}
return {
header: templateExample.header,
body: processedBody,
buttons: templateExample.buttons,
};
}
// Fallback for templates not in our examples - use raw template data
if (props.selectedTemplate.components) {
const components = props.selectedTemplate.components;
const headerComponent = components.find(c => c.type === 'HEADER');
const bodyComponent = components.find(c => c.type === 'BODY');
const buttonComponent = components.find(c => c.type === 'BUTTONS');
let processedBody = bodyComponent
? bodyComponent.text || 'Message content'
: 'No message content';
// Replace variables in body text with actual values
if (
props.templateVariables &&
Object.keys(props.templateVariables).length > 0
) {
Object.entries(props.templateVariables).forEach(([key, value]) => {
const pattern = `{{${key}}}`;
processedBody = processedBody.replace(
new RegExp(pattern.replace(/[{}]/g, '\\$&'), 'g'),
value || `{{${key}}}`
);
});
}
return {
header: headerComponent
? {
type: headerComponent.format || 'TEXT',
content: headerComponent.text || 'Header',
}
: null,
body: processedBody,
buttons: buttonComponent ? buttonComponent.buttons : null,
};
}
return null;
};
const templatePreview = computed(() => getTemplatePreview());
</script>
<template>
<div class="h-full bg-white rounded-lg border border-n-slate-4">
<!-- Header -->
<div class="p-4 border-b border-n-slate-4">
<h3 class="text-lg font-semibold text-n-slate-12">
{{ t('CAMPAIGN.PLAYGROUND.TITLE') }}
</h3>
</div>
<!-- WhatsApp Preview Container -->
<div class="flex flex-col p-4 h-96">
<!-- Empty State (No Template Selected) -->
<div
v-if="!templatePreview"
class="flex flex-col flex-1 justify-center items-center"
>
<div class="text-center">
<h4 class="mb-1 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.PLAYGROUND.EMPTY_STATE.TITLE') }}
</h4>
<p class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.PLAYGROUND.EMPTY_STATE.SUBTITLE') }}
</p>
</div>
</div>
<!-- Template Preview (Template Selected) -->
<div v-else>
<!-- WhatsApp-style Message Bubble -->
<div class="flex justify-center">
<div
class="p-3 rounded-lg border bg-n-slate-2 text-n-slate-12 border-n-slate-4"
>
<!-- Header Media/Text (if exists) -->
<div v-if="templatePreview.header" class="mb-2">
<div
v-if="templatePreview.header.type === 'IMAGE'"
class="p-2 -m-3 mb-2 text-xs text-center rounded-t-lg bg-n-slate-3"
>
{{ templatePreview.header.content }}
</div>
<div
v-else-if="templatePreview.header.type === 'VIDEO'"
class="p-2 -m-3 mb-2 text-xs text-center rounded-t-lg bg-n-slate-3"
>
{{ templatePreview.header.content }}
</div>
<div
v-else-if="templatePreview.header.type === 'TEXT'"
class="mb-1 text-sm font-semibold"
>
{{ templatePreview.header.content }}
</div>
</div>
<!-- Message Body -->
<div class="text-sm leading-relaxed whitespace-pre-wrap">
{{ templatePreview.body }}
</div>
<!-- Action Buttons -->
<div v-if="templatePreview.buttons" class="-m-3 mt-2 mt-3">
<div class="pt-2 border-t border-n-slate-4">
<div
v-for="(button, index) in templatePreview.buttons"
:key="index"
class="py-2 text-sm font-medium text-center border-b transition-colors cursor-pointer border-n-slate-4 last:border-b-0 hover:bg-n-slate-3 text-n-slate-12"
>
<!-- eslint-disable-next-line vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
<span v-if="button.type === 'URL'">🔗</span>
<!-- eslint-disable-next-line vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
<span v-else-if="button.type === 'PHONE_NUMBER'">📞</span>
<!-- eslint-disable-next-line vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
<span v-else-if="button.type === 'COPY_CODE'">📋</span>
{{ button.text }}
</div>
</div>
</div>
</div>
</div>
<!-- Spacer -->
<div class="flex-1 min-h-4" />
<!-- Preview Note -->
<div class="p-3 rounded-lg bg-n-alpha-2">
<!-- eslint-disable-next-line vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
<p class="text-xs leading-relaxed text-center text-n-slate-11">
This is a preview of how your WhatsApp message will appear to
recipients and may vary slightly depending on the device. Perform a
test send to confirm the final appearance.
</p>
</div>
</div>
</div>
</div>
</template>
@@ -1,496 +0,0 @@
<script setup>
import { reactive, computed, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Accordion from 'dashboard/components-next/Accordion/Accordion.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
campaign: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit', 'templateChange', 'variablesChange']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
getFilteredWhatsAppTemplates: useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
),
};
const initialState = {
title: '',
description: '',
message: '',
templateId: null,
selectedAudience: [],
scheduledAt: null,
campaignType: 'ongoing',
};
const state = reactive({ ...initialState });
const templateParserRef = ref(null);
const templateVariables = ref({});
const validationRules = {
title: { required, minLength: minLength(1) },
templateId: { required },
selectedAudience: { required },
};
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = field => {
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title'),
template: getErrorMessage('templateId'),
audience: getErrorMessage('selectedAudience'),
}));
const templateOptions = computed(() => {
if (!props.campaign.inbox.id) return [];
const templates = formState.getFilteredWhatsAppTemplates.value(
props.campaign.inbox.id
);
return templates.map(template => {
const friendlyName = template.name
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
return {
value: template.id,
label: `${friendlyName} (${template.language || 'en'})`,
template: template,
};
});
});
const selectedTemplate = computed(() => {
if (!state.templateId) return null;
return templateOptions.value.find(option => option.value === state.templateId)
?.template;
});
const audienceList = computed(() => {
return (
formState.labels.value?.map(label => ({
value: label.id,
label: label.title,
})) ?? []
);
});
const selectedLabels = computed(() => {
if (!state.selectedAudience?.length) return [];
return (
formState.labels.value?.filter(label =>
state.selectedAudience.includes(label.id)
) || []
);
});
const totalContactsForSelectedLabels = computed(() => {
// Mock calculation - in real implementation this would come from the API
return selectedLabels.value.reduce((total, label) => {
// Mock contact counts for demonstration
const mockCounts = {
premium: 909,
billing: 1000,
};
const labelName = label.title.toLowerCase();
return (
total + (mockCounts[labelName] || Math.floor(Math.random() * 500) + 100)
);
}, 0);
});
const updateStateFromCampaign = campaign => {
state.title = campaign.title || '';
state.description = campaign.description || '';
state.message = campaign.message || '';
state.templateId = campaign.template_params?.name
? templateOptions.value.find(
opt => opt.template.name === campaign.template_params.name
)?.value || null
: null;
state.selectedAudience = campaign.audience?.map(aud => aud.id) || [];
state.scheduledAt = campaign.scheduled_at || null;
state.campaignType = campaign.campaign_type || 'ongoing';
};
const handleBasicDetailsUpdate = async () => {
const result = await Promise.all([v$.value.title.$validate()]).then(results =>
results.every(Boolean)
);
if (!result) return;
const payload = {
title: state.title,
description: state.description,
};
emit('submit', payload);
};
const handleSelectTemplateUpdate = async () => {
const result = await v$.value.templateId.$validate();
if (!result) return;
const currentTemplate = selectedTemplate.value;
const parserData = templateParserRef.value;
const templateContent = parserData?.renderedTemplate || '';
const templateParams = {
name: currentTemplate?.name || '',
namespace: currentTemplate?.namespace || '',
category: currentTemplate?.category || 'UTILITY',
language: currentTemplate?.language || 'en_US',
processed_params: parserData?.processedParams || {},
};
const payload = {
message: templateContent,
template_params: templateParams,
};
emit('submit', payload);
};
const handleSelectAudienceUpdate = async () => {
const result = await v$.value.selectedAudience.$validate();
if (!result) return;
const payload = {
audience: state.selectedAudience?.map(id => ({
id,
type: 'Label',
})),
};
emit('submit', payload);
};
const handleScheduleTemplateUpdate = () => {
const payload = {
scheduled_at: state.scheduledAt,
campaign_type: state.campaignType,
};
emit('submit', payload);
};
const handleViewContacts = label => {
// Mock function - in real implementation this would navigate to contacts filtered by label
// eslint-disable-next-line no-console
console.log(`Viewing contacts for label: ${label.title}`);
// In real implementation: router.push({ name: 'contacts', query: { label: label.id } });
};
const handleVariablesUpdate = variables => {
templateVariables.value = variables || {};
emit('variablesChange', templateVariables.value);
};
watch(
() => props.campaign,
newCampaign => {
if (props.mode === 'edit' && newCampaign) {
updateStateFromCampaign(newCampaign);
}
},
{ immediate: true }
);
watch(
() => selectedTemplate.value,
newTemplate => {
emit('templateChange', newTemplate);
},
{ immediate: true }
);
watch(
() => templateVariables.value,
newVariables => {
emit('variablesChange', newVariables);
},
{ deep: true }
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<!-- Basic Details Section -->
<Accordion :title="t('CAMPAIGN.FORM.SECTIONS.BASIC_DETAILS')" is-open>
<div class="flex flex-col gap-4 pt-4">
<Input
v-model="state.title"
:label="t('CAMPAIGN.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<Editor
v-model="state.description"
:label="t('CAMPAIGN.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAMPAIGN.FORM.DESCRIPTION.PLACEHOLDER')"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleBasicDetailsUpdate"
>
{{ t('CAMPAIGN.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
<!-- Select Template Section -->
<Accordion :title="t('CAMPAIGN.FORM.SECTIONS.SELECT_TEMPLATE')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-1">
<label
for="template"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ t('CAMPAIGN.FORM.TEMPLATE.LABEL') }}
</label>
<ComboBox
id="template"
v-model="state.templateId"
:options="templateOptions"
:has-error="!!formErrors.template"
:placeholder="t('CAMPAIGN.FORM.TEMPLATE.SELECT')"
:message="formErrors.template"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
<p class="mt-1 text-xs text-n-slate-11">
{{ t('CAMPAIGN.FORM.TEMPLATE.INFO') }}
</p>
</div>
<!-- Template Parser -->
<WhatsAppTemplateParser
v-if="selectedTemplate"
ref="templateParserRef"
:template="selectedTemplate"
@variables-update="handleVariablesUpdate"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleSelectTemplateUpdate"
>
{{ t('CAMPAIGN.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
<!-- Select Audience Section -->
<Accordion :title="t('CAMPAIGN.FORM.SECTIONS.SELECT_AUDIENCE')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-1">
<label
for="audience"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ t('CAMPAIGN.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.FORM.AUDIENCE.SELECT')"
:has-error="!!formErrors.audience"
:message="formErrors.audience"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<!-- Contact Count Information -->
<div v-if="selectedLabels.length > 0" class="flex flex-col gap-2 mt-2">
<!-- Individual label contact counts -->
<div class="space-y-1">
<div
v-for="label in selectedLabels"
:key="label.id"
class="flex gap-2 items-center text-sm text-n-slate-11"
>
<div>
<span class="font-medium">
{{ t('CAMPAIGN.FORM.CONTACT_COUNT.LABEL_PREFIX')
}}{{ label.title.toLowerCase() }}
</span>
<span>
{{ t('CAMPAIGN.FORM.CONTACT_COUNT.HAS_AROUND') }}
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
{{ label.title.toLowerCase() === 'premium' ? '909' : '1000' }}
{{ t('CAMPAIGN.FORM.CONTACT_COUNT.CONTACTS_TAGGED') }}
</span>
</div>
<Button
:label="t('CAMPAIGN.FORM.CONTACT_COUNT.VIEW_CONTACTS')"
variant="link"
size="xs"
@click="handleViewContacts(label)"
/>
</div>
</div>
<!-- Total contact count -->
<div class="p-3 mt-2 rounded-md bg-n-alpha-2">
<p class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.FORM.CONTACT_COUNT.SENDING_TO') }}
{{ totalContactsForSelectedLabels }}
{{ t('CAMPAIGN.FORM.CONTACT_COUNT.CONTACTS_TOTAL') }}
</p>
</div>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleSelectAudienceUpdate"
>
{{ t('CAMPAIGN.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
<!-- Account Health Section -->
<Accordion :title="t('CAMPAIGN.FORM.SECTIONS.ACCOUNT_HEALTH')">
<div class="flex flex-col gap-6 pt-4">
<!-- Messaging Tier Card -->
<div class="p-4 border rounded-lg bg-n-alpha-1 border-n-weak">
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<span class="i-lucide-gauge text-lg text-n-slate-10" />
<div>
<h4 class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.MESSAGING_TIER') }}
</h4>
<p class="text-xs text-n-slate-11 mt-1">
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.TIER_DESCRIPTION') }}
</p>
</div>
</div>
<a
href="https://www.zoko.io/learning-article/whatsapp-business-api-messaging-limits-and-how-to-upgrade-to-the-next-tier"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-n-blue-11 hover:text-n-blue-12 text-xs underline"
>
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.LEARN_MORE') }}
<span class="i-lucide-external-link text-xs" />
</a>
</div>
</div>
<!-- Account Health Status -->
<div class="p-4 border rounded-lg bg-n-alpha-1 border-n-weak">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="i-lucide-heart-pulse text-lg text-green-600" />
<div>
<h4 class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.ACCOUNT_HEALTH') }}
</h4>
<p class="text-xs text-n-slate-11 mt-1">
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.QUALITY_RATING') }}
</p>
</div>
</div>
<span
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-white bg-green-600 rounded-full"
>
<span class="w-1.5 h-1.5 bg-white rounded-full mr-1.5" />
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.HEALTH_GREEN') }}
</span>
</div>
</div>
<!-- Account Status -->
<div class="p-4 border rounded-lg bg-n-alpha-1 border-n-weak">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="i-lucide-wifi text-lg text-blue-600" />
<div>
<h4 class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.ACCOUNT_STATUS') }}
</h4>
<p class="text-xs text-n-slate-11 mt-1">
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.API_CONNECTION') }}
</p>
</div>
</div>
<span
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-100 rounded-full"
>
<span class="w-1.5 h-1.5 bg-blue-600 rounded-full mr-1.5" />
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.STATUS_CONNECTED') }}
</span>
</div>
</div>
</div>
</Accordion>
<!-- Schedule Template Section -->
<Accordion :title="t('CAMPAIGN.FORM.SECTIONS.SCHEDULE_TEMPLATE')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.FORM.SCHEDULED_AT.LABEL') }}
</label>
<input
v-model="state.scheduledAt"
type="datetime-local"
class="p-2 w-full rounded-md border"
/>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleScheduleTemplateUpdate"
>
{{ t('CAMPAIGN.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
</form>
</template>
@@ -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">
@@ -201,87 +201,6 @@
}
}
},
"EDIT": {
"SUCCESS_MESSAGE": "Campaign updated successfully",
"ERROR_MESSAGE": "There was an error. Please try again.",
"NOT_FOUND": "Campaign not found"
},
"FORM": {
"SECTIONS": {
"BASIC_DETAILS": "Basic Details",
"SELECT_TEMPLATE": "Select Template",
"SELECT_AUDIENCE": "Select Audience",
"ACCOUNT_HEALTH": "Account Health",
"SCHEDULE_TEMPLATE": "Schedule Template"
},
"TITLE": {
"LABEL": "Campaign Title",
"PLACEHOLDER": "Enter campaign title"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter campaign description"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Enter your campaign message"
},
"TEMPLATE": {
"LABEL": "Template",
"SELECT": "Select a template",
"INFO": "Select a template to use for this campaign."
},
"AUDIENCE": {
"LABEL": "Audience",
"SELECT": "Select audience"
},
"CAMPAIGN_TYPE": {
"LABEL": "Campaign Type",
"ONGOING": "Ongoing",
"ONE_OFF": "One-off"
},
"SCHEDULED_AT": {
"LABEL": "Schedule Time"
},
"UPDATE": "Update",
"CONTACT_COUNT": {
"LABEL_PREFIX": "#",
"HAS_AROUND": "has around",
"CONTACTS_TAGGED": "contacts tagged",
"VIEW_CONTACTS": "view contacts",
"SENDING_TO": "You are sending this campaign to",
"CONTACTS_TOTAL": "contacts"
},
"ACCOUNT_HEALTH": {
"MESSAGING_TIER": "Messaging Tier:",
"ACCOUNT_HEALTH": "Account Health:",
"ACCOUNT_STATUS": "Account Status:",
"TIER_DESCRIPTION": "TIER 3 (MAX 100,000 CUSTOMERS IN 24 HOURS)",
"STATUS_CONNECTED": "CONNECTED",
"HEALTH_GREEN": "GREEN",
"HEALTH_YELLOW": "YELLOW",
"HEALTH_RED": "RED",
"LEARN_MORE": "Learn more about Tiers",
"QUALITY_RATING": "Quality rating",
"API_CONNECTION": "API connection"
}
},
"PLAYGROUND": {
"TITLE": "Template Preview",
"CAMPAIGN_TITLE": "Campaign Title",
"MESSAGE_PREVIEW": "Message Preview",
"TEMPLATE": "Template",
"AUDIENCE": "Audience",
"CAMPAIGN_TYPE": "Campaign Type",
"NO_TITLE": "No title set",
"NO_MESSAGE": "No message set",
"NO_TYPE": "No type set",
"PREVIEW_NOTE": "This is a preview of how your campaign will appear to recipients.",
"EMPTY_STATE": {
"TITLE": "Select a template to preview",
"SUBTITLE": "Choose a WhatsApp template from the 'Select Template' section above to see how your WhatsApp message will appear to recipients."
}
},
"CONFIRM_DELETE": {
"TITLE": "Are you sure to delete?",
"DESCRIPTION": "The delete action is permanent and cannot be reversed.",
@@ -226,6 +226,7 @@
"APPEARANCE": "Change appearance",
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
"DOCS": "Read documentation",
"CHANGELOG": "Changelog",
"LOGOUT": "Log out"
},
"APP_GLOBAL": {
@@ -4,7 +4,6 @@ import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue';
import CampaignEdit from './pages/CampaignEdit.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const meta = {
@@ -63,17 +62,6 @@ const campaignsRoutes = {
},
],
},
{
path: frontendURL(
'accounts/:accountId/campaigns/whatsapp/:campaignId/edit'
),
name: 'campaigns_whatsapp_edit',
meta: {
...meta,
featureFlag: FEATURE_FLAGS.WHATSAPP_CAMPAIGNS,
},
component: CampaignEdit,
},
],
};
@@ -1,116 +0,0 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import EditCampaignForm from 'dashboard/components-next/captain/pageComponents/campaign/EditCampaignForm.vue';
import CampaignPlayground from 'dashboard/components-next/captain/campaign/CampaignPlayground.vue';
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const campaignId = route.params.campaignId;
const selectedTemplate = ref(null);
const templateVariables = ref({});
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingItem);
const allCampaigns = computed(() => store.getters['campaigns/getAllCampaigns']);
const campaign = computed(() =>
allCampaigns.value.find(c => c.id === Number(campaignId))
);
const isCampaignAvailable = computed(() => !!campaign.value?.id);
const campaignStatus = computed(() => {
if (!campaign.value) return '';
// For WhatsApp campaigns, check if it's completed or scheduled
const STATUS_COMPLETED = 'completed';
return campaign.value.campaign_status === STATUS_COMPLETED
? t('CAMPAIGN.WHATSAPP.CARD.STATUS.COMPLETED')
: t('CAMPAIGN.WHATSAPP.CARD.STATUS.SCHEDULED');
});
const statusTextColor = computed(() => {
if (!campaign.value) return 'text-n-slate-12';
const STATUS_COMPLETED = 'completed';
const isActive = campaign.value.campaign_status !== STATUS_COMPLETED;
return !isActive ? 'text-n-teal-11' : 'text-n-slate-12';
});
const handleSubmit = async updatedCampaign => {
try {
await store.dispatch('campaigns/update', {
id: campaignId,
...updatedCampaign,
});
useAlert(t('CAMPAIGN.EDIT.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage = error?.message || t('CAMPAIGN.EDIT.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleTemplateChange = template => {
selectedTemplate.value = template;
};
const handleVariablesChange = variables => {
templateVariables.value = variables;
};
onMounted(() => {
if (!isCampaignAvailable.value) {
store.dispatch('campaigns/get');
}
});
</script>
<template>
<PageLayout
:show-pagination-footer="false"
:is-fetching="isFetching"
:show-know-more="false"
:back-url="{ name: 'campaigns_ongoing_index' }"
>
<template #headerTitle>
<div class="flex gap-2 items-center">
<span>{{ campaign?.title }}</span>
<span
class="inline-flex items-center px-2 py-0.5 h-6 text-xs font-medium rounded-md bg-n-alpha-2"
:class="statusTextColor"
>
{{ campaignStatus }}
</span>
</div>
</template>
<template #body>
<div v-if="!isCampaignAvailable">
{{ t('CAMPAIGN.EDIT.NOT_FOUND') }}
</div>
<div v-else class="flex gap-4 h-full">
<div class="flex-1 pr-4 h-full lg:overflow-auto md:h-auto">
<EditCampaignForm
:campaign="campaign"
mode="edit"
@submit="handleSubmit"
@template-change="handleTemplateChange"
@variables-change="handleVariablesChange"
/>
</div>
<div class="w-[400px] hidden lg:block h-full">
<CampaignPlayground
:selected-template="selectedTemplate"
:template-variables="templateVariables"
/>
</div>
</div>
</template>
</PageLayout>
</template>
+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.0'
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
@@ -0,0 +1,8 @@
require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'
OpenTelemetry::SDK.configure do |c|
c.service_name = 'ChatwootAppCaptain'
end
CaptainTracer = OpenTelemetry.tracer_provider.tracer('CaptainTracer')
+2
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
+9
View File
@@ -217,6 +217,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
+43 -18
View File
@@ -1,18 +1,35 @@
module Captain::ChatHelper
def request_chat_completion
log_chat_completion_request
CaptainTracer.in_span('llm_chat_completion') do |span|
# span.set_attribute('model', @model)
# span.set_attribute('messages', @messages)
# span.set_attribute('tools', @tool_registry&.registered_tools || [])
span.set_attribute('input', @messages.last[:content])
span.set_attribute('temperature', @assistant&.config&.[]('temperature').to_f || 1)
response = @client.chat(
parameters: {
model: @model,
messages: @messages,
tools: @tool_registry&.registered_tools || [],
response_format: { type: 'json_object' },
temperature: @assistant&.config&.[]('temperature').to_f || 1
}
)
log_chat_completion_request
handle_response(response)
response = @client.chat(
parameters: {
model: @model,
messages: @messages,
tools: @tool_registry&.registered_tools || [],
response_format: { type: 'json_object' },
temperature: @assistant&.config&.[]('temperature').to_f || 1
}
)
span.set_attribute('model', response['model'])
span.set_attribute('llm.usage.cached_tokens', response.dig('usage', 'prompt_tokens_details', 'cached_tokens'))
# span.set_attribute('llm.usage.input_tokens', response.dig('usage', 'input_tokens'))
span.set_attribute('llm.usage.output_tokens', response.dig('usage', 'output_tokens'))
span.set_attribute('llm.usage.total_tokens', response.dig('usage', 'total_tokens'))
span.set_attribute('llm.usage.completion_tokens', response.dig('usage', 'completion_tokens'))
span.set_attribute('llm.completions.0.role', response['role'])
span.set_attribute('llm.completions.0.content', response['content']&.first&.[]('text') || 'empty-response')
handle_response(response)
end
rescue StandardError => e
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
raise e
@@ -41,14 +58,22 @@ module Captain::ChatHelper
end
def process_tool_call(tool_call)
arguments = JSON.parse(tool_call['function']['arguments'])
function_name = tool_call['function']['name']
tool_call_id = tool_call['id']
CaptainTracer.in_span('llm_tool_call') do |span|
arguments = JSON.parse(tool_call['function']['arguments'])
function_name = tool_call['function']['name']
tool_call_id = tool_call['id']
span.set_attribute('function_name', function_name)
if arguments.present?
arguments.each do |key, value|
span.set_attribute("arguments.#{key}", value)
end
end
if @tool_registry.respond_to?(function_name)
execute_tool(function_name, arguments, tool_call_id)
else
process_invalid_tool_call(function_name, tool_call_id)
if @tool_registry.respond_to?(function_name)
execute_tool(function_name, arguments, tool_call_id)
else
process_invalid_tool_call(function_name, tool_call_id)
end
end
end
@@ -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
@@ -20,16 +20,23 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
end
def generate_response(input)
@messages << { role: 'user', content: input } if input.present?
response = request_chat_completion
CaptainTracer.in_span('copilot_generate_response') do |span|
span.set_attribute('assistant_id', @assistant.id)
span.set_attribute('account_id', @account.id)
span.set_attribute('user_id', @user.id) if @user.present?
span.set_attribute('copilot_thread_id', @copilot_thread.id) if @copilot_thread.present?
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Incrementing response usage for account #{@account.id}"
)
@account.increment_response_usage
@messages << { role: 'user', content: input } if input.present?
response = request_chat_completion
response
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Incrementing response usage for account #{@account.id}"
)
@account.increment_response_usage
response
end
end
private
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.4.0",
"version": "4.5.0",
"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,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
+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
+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