Compare commits

..
Author SHA1 Message Date
Muhsin Keloth 1df19772a4 chore: make campaign ui better 2025-08-17 18:10:56 +05:30
Muhsin Keloth 090d066f9f chore: add playground 2025-08-17 17:55:56 +05:30
Muhsin Keloth 51d0acb90c chore: account health 2025-08-17 17:32:24 +05:30
Muhsin Keloth e5104e0b5e Update EditCampaignForm.vue 2025-08-17 16:39:51 +05:30
Muhsin Keloth e22397f8af chore: add contacts count section 2025-08-17 16:36:29 +05:30
Muhsin Keloth dd2523d553 feat: add basic ui 2025-08-17 16:17:27 +05:30
45 changed files with 984 additions and 1055 deletions
+1 -18
View File
@@ -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
+1 -4
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 '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
View File
@@ -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
@@ -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>
@@ -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>
-1
View File
@@ -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
-37
View File
@@ -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')
-2
View File
@@ -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
-21
View File
@@ -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
-21
View File
@@ -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
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.5.0'
version: '4.4.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 '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?
-4
View File
@@ -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')
-2
View File
@@ -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
-9
View File
@@ -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
+18 -43
View File
@@ -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
View File
@@ -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
-12
View File
@@ -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
-56
View File
@@ -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