Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1df19772a4 | ||
|
|
090d066f9f | ||
|
|
51d0acb90c | ||
|
|
e5104e0b5e | ||
|
|
e22397f8af | ||
|
|
dd2523d553 |
@@ -55,21 +55,4 @@
|
||||
|
||||
## Ruby Best Practices
|
||||
|
||||
- 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.
|
||||
- Use compact `module/class` definitions; avoid nested styles
|
||||
@@ -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 'datadog', '~> 2.0', require: false
|
||||
gem 'ddtrace', require: false
|
||||
gem 'elastic-apm', require: false
|
||||
gem 'newrelic_rpm', require: false
|
||||
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
||||
@@ -186,9 +186,6 @@ gem 'ruby_llm-schema'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
gem 'opentelemetry-sdk'
|
||||
gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
|
||||
+4
-39
@@ -194,14 +194,10 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
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)
|
||||
ddtrace (0.48.0)
|
||||
ffi (~> 1.0)
|
||||
msgpack
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
reline (>= 0.3.1)
|
||||
@@ -448,16 +444,6 @@ 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)
|
||||
@@ -564,25 +550,6 @@ 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)
|
||||
@@ -961,7 +928,7 @@ DEPENDENCIES
|
||||
commonmarker
|
||||
csv-safe
|
||||
database_cleaner
|
||||
datadog (~> 2.0)
|
||||
ddtrace
|
||||
debug (~> 1.8)
|
||||
devise (>= 4.9.4)
|
||||
devise-secure_password!
|
||||
@@ -1013,8 +980,6 @@ 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
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_assignment_policy
|
||||
before_action -> { check_authorization(AssignmentPolicy) }
|
||||
|
||||
def index
|
||||
@inboxes = @assignment_policy.inboxes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(
|
||||
params[:assignment_policy_id]
|
||||
)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:assignment_policy_id)
|
||||
end
|
||||
end
|
||||
@@ -1,36 +0,0 @@
|
||||
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
|
||||
@@ -1,46 +0,0 @@
|
||||
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
|
||||
+3
-3
@@ -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 Lumora.",
|
||||
companyName: 'Lumora',
|
||||
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Chatwoot.",
|
||||
companyName: 'Chatwoot',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'candice-dev',
|
||||
@@ -16,7 +16,7 @@ export default [
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'candice.matherson@lumora.com',
|
||||
email: 'candice.matherson@chatwoot.com',
|
||||
id: 22,
|
||||
name: 'Candice Matherson',
|
||||
phoneNumber: '+14155552671',
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<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>
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
<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,15 +86,6 @@ 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,
|
||||
@@ -123,7 +114,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 p-1 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
|
||||
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
|
||||
:class="{ 'bg-n-alpha-1': isOpen }"
|
||||
@click="toggle"
|
||||
>
|
||||
@@ -136,16 +127,16 @@ const allowedMenuItems = computed(() => {
|
||||
rounded-full
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
|
||||
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
|
||||
{{ currentUser.available_name }}
|
||||
</div>
|
||||
<div class="text-xs truncate text-n-slate-11">
|
||||
<div class="text-n-slate-11 text-xs truncate">
|
||||
{{ currentUser.email }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<DropdownBody class="bottom-12 z-50 mb-2 w-80 ltr:left-0 rtl:right-0">
|
||||
<DropdownBody class="ltr:left-0 rtl:right-0 bottom-12 z-50 w-80 mb-2">
|
||||
<SidebarProfileMenuStatus />
|
||||
<DropdownSeparator />
|
||||
<template v-for="item in allowedMenuItems" :key="item.label">
|
||||
|
||||
@@ -201,6 +201,87 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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,7 +226,6 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 = {
|
||||
@@ -62,6 +63,17 @@ const campaignsRoutes = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL(
|
||||
'accounts/:accountId/campaigns/whatsapp/:campaignId/edit'
|
||||
),
|
||||
name: 'campaigns_whatsapp_edit',
|
||||
meta: {
|
||||
...meta,
|
||||
featureFlag: FEATURE_FLAGS.WHATSAPP_CAMPAIGNS,
|
||||
},
|
||||
component: CampaignEdit,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<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>
|
||||
@@ -61,7 +61,6 @@ 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
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# == 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')
|
||||
@@ -67,8 +67,6 @@ 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
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# == 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
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
@@ -1,10 +0,0 @@
|
||||
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
|
||||
@@ -1 +0,0 @@
|
||||
json.partial! 'assignment_policy', assignment_policy: @assignment_policy
|
||||
@@ -1,5 +0,0 @@
|
||||
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
|
||||
@@ -1,3 +0,0 @@
|
||||
json.inboxes @inboxes do |inbox|
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: inbox
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
json.array! @assignment_policies do |assignment_policy|
|
||||
json.partial! 'assignment_policy', assignment_policy: assignment_policy
|
||||
end
|
||||
@@ -1 +0,0 @@
|
||||
json.partial! 'assignment_policy', assignment_policy: @assignment_policy
|
||||
@@ -1 +0,0 @@
|
||||
json.partial! 'assignment_policy', assignment_policy: @assignment_policy
|
||||
@@ -1 +0,0 @@
|
||||
json.partial! 'api/v1/accounts/assignment_policies/assignment_policy', formats: [:json], assignment_policy: @assignment_policy
|
||||
@@ -1 +0,0 @@
|
||||
json.partial! 'api/v1/accounts/assignment_policies/assignment_policy', formats: [:json], assignment_policy: @assignment_policy
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.5.0'
|
||||
version: '4.4.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -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 'datadog' if ENV.fetch('DD_TRACE_AGENT_URL', false).present?
|
||||
require 'ddtrace' 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?
|
||||
|
||||
|
||||
@@ -191,7 +191,3 @@
|
||||
display_name: CRM V2
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: assignment_v2
|
||||
display_name: Assignment V2
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
require 'opentelemetry/sdk'
|
||||
require 'opentelemetry/exporter/otlp'
|
||||
|
||||
OpenTelemetry::SDK.configure do |c|
|
||||
c.service_name = 'ChatwootAppCaptain'
|
||||
end
|
||||
|
||||
CaptainTracer = OpenTelemetry.tracer_provider.tracer('CaptainTracer')
|
||||
@@ -53,8 +53,6 @@ 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
|
||||
|
||||
@@ -217,15 +217,6 @@ 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,35 +1,18 @@
|
||||
module Captain::ChatHelper
|
||||
def request_chat_completion
|
||||
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)
|
||||
log_chat_completion_request
|
||||
|
||||
log_chat_completion_request
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
handle_response(response)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
|
||||
raise e
|
||||
@@ -58,22 +41,14 @@ module Captain::ChatHelper
|
||||
end
|
||||
|
||||
def process_tool_call(tool_call)
|
||||
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
|
||||
arguments = JSON.parse(tool_call['function']['arguments'])
|
||||
function_name = tool_call['function']['name']
|
||||
tool_call_id = 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
|
||||
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
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
module Enterprise::Concerns::AssignmentPolicy
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
enum assignment_order: { round_robin: 0, balanced: 1 } if ChatwootApp.enterprise?
|
||||
end
|
||||
end
|
||||
@@ -20,23 +20,16 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
end
|
||||
|
||||
def generate_response(input)
|
||||
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?
|
||||
@messages << { role: 'user', content: input } if input.present?
|
||||
response = request_chat_completion
|
||||
|
||||
@messages << { role: 'user', content: input } if input.present?
|
||||
response = request_chat_completion
|
||||
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
|
||||
|
||||
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
|
||||
response
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.5.0",
|
||||
"version": "4.4.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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
|
||||
@@ -1,326 +0,0 @@
|
||||
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
|
||||
@@ -1,195 +0,0 @@
|
||||
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
|
||||
@@ -1,18 +0,0 @@
|
||||
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
|
||||
@@ -1,12 +0,0 @@
|
||||
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
|
||||
@@ -1,6 +0,0 @@
|
||||
FactoryBot.define do
|
||||
factory :inbox_assignment_policy do
|
||||
inbox
|
||||
assignment_policy
|
||||
end
|
||||
end
|
||||
@@ -1,56 +0,0 @@
|
||||
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
|
||||
Reference in New Issue
Block a user