Compare commits

...
Author SHA1 Message Date
Sivin VargheseandGitHub 40a8cc8498 Merge branch 'develop' into chore/message-view-to-composition-API 2025-12-01 13:37:22 +05:30
Sivin VargheseandGitHub 1df5fd513a fix: Reduce unnecessary label suggestion API calls (#12978) 2025-12-01 13:36:34 +05:30
iamsivin 37d3c0803b chore: Convert MessagesView from Options API to Composition API 2025-12-01 11:34:46 +05:30
iamsivin 6525d93370 fix: Reduce unnecessary label suggestion API calls 2025-12-01 10:53:10 +05:30
Muhsin KelothandGitHub 4627aad56d fix: Enable reply editor for API channels with templates (#12973) 2025-12-01 10:13:38 +05:30
f23d95e004 feat: Add Pinia support and relocate store factory (#12854)
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-11-28 16:31:59 +05:30
1ef945de7b feat: Instrument captain (#12949)
Co-authored-by: aakashb95 <aakash@chatwoot.com>
2025-11-28 15:12:55 +05:30
Sivin VargheseandGitHub 92fc286ecb fix: Resolve z-index issue in assistant switcher (#12969) 2025-11-27 10:35:42 +05:30
Sivin VargheseandGitHub dedb0426fb chore: Improve pagination with compact number formatting and pluralization (#12962) 2025-11-27 10:32:34 +05:30
Vinay KeerthiandGitHub eb0410af53 fix: handle string return values in MailPresenter#from method (#12966) 2025-11-27 10:31:56 +05:30
3ded13c8d5 chore(revert): switch label suggestions back gpt 4 mini (#12959)
Co-authored-by: aakashb95 <aakash@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-11-27 10:27:11 +05:30
f1079574e3 fix: Disable SSL verification for LINE webhooks in development (#12960)
## Summary

- Fixes SSL certificate verification errors when testing LINE webhooks
in development environments
- Configures the LINE Bot API client to skip SSL verification only in
development mode

## Background

When testing LINE webhooks locally on macOS, the LINE Bot SDK was
failing with:
```
OpenSSL::SSL::SSLError: SSL_connect returned=1 errno=0 state=error: certificate verify failed (unable to get certificate CRL)
```

This occurs because the LINE Bot SDK tries to fetch user profiles via
HTTPS, and OpenSSL on macOS development environments may not have proper
access to Certificate Revocation Lists (CRLs).

## Changes

- Added `http_options` configuration to the LINE Bot client in
`Channel::Line#client`
- Sets `verify_mode: OpenSSL::SSL::VERIFY_NONE` only when
`Rails.env.development?` is true
- Production environments continue to use full SSL verification for
security

## Test Plan

- [x] Send a LINE message to trigger webhook in development
- [x] Verify webhook is processed without SSL errors
- [x] Confirm change only applies in development mode

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-11-26 22:38:26 +05:30
Muhsin KelothandGitHub 73ad27e94c fix: Show WhatsApp templates only for inboxes with templates (#12965)
Fixed the logic to show WhatsApp templates option only for inboxes that have templates available.
2025-11-26 21:01:31 +05:30
Muhsin KelothandGitHub 6a83cad69d fix: Prevent WhatsApp campaign duplicate messages by updating status immediately (#12955)
Fixes
https://linear.app/chatwoot/issue/CW-5918/whatsapp-campaigns-running-in-parallel-and-duplicating-messages
2025-11-26 15:25:49 +05:30
Gabriel JablonskiandGitHub 7e0507e3b5 fix: invalid language tag in heatmap component in reports page (#12952)
## Description

This PR fixes a `RangeError: Invalid language tag` that occurs in the
Heatmap report when using locales with underscores (e.g., `pt_BR`,
`zh_TW`).

The issue was caused by passing the raw locale string from `vue-i18n`
(which uses underscores for some regions) directly to
`Intl.DateTimeFormat`. The `Intl` API expects BCP 47 language tags which
use hyphens (e.g., `pt-BR`).

This change sanitizes the locale string by replacing underscores with
hyphens before creating the `DateTimeFormat` instance.

Fixes #12951
2025-11-26 13:24:29 +05:30
Vinay KeerthiandGitHub 94a9e4e067 chore: Add custom RuboCop cop to enforce one class per file (#12947) 2025-11-26 12:53:04 +05:30
Shivam MishraandGitHub 7b20ecd27b chore: Revert pagination formatting and pluralization (#12954) 2025-11-25 21:36:13 +05:30
Gabriel JablonskiandGitHub e635122ff6 fix: add presence validation for account name (#12636)
## Description

When a user tries creating a new account through the Super Admin
dashboard, and they forget to fill in the account name, they're faced
with an ugly error (generic "Something went wrong" on production).

This PR simply adds the `validates :name, presence: true` model
validation on `Account` model, which is translated as a proper error
message on the Super Admin UI.
2025-11-25 20:11:15 +05:30
Aakash BakhleandGitHub fa07b9158a fix: switch label suggestions to gpt-5-nano (#12945) 2025-11-25 19:36:47 +05:30
Sivin VargheseandGitHub 46eb6f39f3 fix: Resolve z-index issue in assistant switcher (#12940) 2025-11-25 18:47:31 +05:30
Sivin VargheseandGitHub f0538b25ed fix: Reactive assistantId issue in Captain Inbox after route changes (#12939) 2025-11-25 18:47:18 +05:30
Shivam MishraandGitHub 389b864020 fix: click event for billing support (#12942) 2025-11-25 14:53:27 +05:30
Sivin VargheseandGitHub 26778156dc chore: Improve pagination with compact number formatting and pluralization (#12921)
# Pull Request Template

## Description

This PR enhances the pagination component with standardized number
formatting and improved i18n handling.

**Includes:**

* Added `formatCompactNumber` and `formatFullNumber` helpers using
`Intl.NumberFormat`.
    * `< 1,000`: show exact value (e.g., `999`)
    * `1,000–999,999`: show compact format (`1k`, `1k+`)
    * `1,000,000+`: show in millions with one decimal (e.g., `1.2M`)
* Updated `PaginationFooter` to use the new formatters for all displayed
numbers.
  * Added proper pluralization to pagination i18n strings.

Fixes
https://linear.app/chatwoot/issue/CW-5999/better-display-of-numbers

## Type of change

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

## How Has This Been Tested?

### Screenshoots

**Before**
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/9fcf8baa-ae32-4a8a-85b0-24002fd863db"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/3d7138b7-133e-4ae6-b55f-67eff73ff1cc"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/1bbf7070-0681-492d-9308-a33874052d28"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/4e441672-26aa-4e66-965e-9edb807eaa72"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/11836702-1b74-4834-8932-31c20adc2db8"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/d37971bc-09af-4238-8601-ccc2ae69dbe7"
/>


**After**
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/8eaf2a23-beea-486b-b555-37f8b36ab904"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/f44f508a-e39d-45cb-afd8-98deb26920f8"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/d3b90711-bd7e-44ee-8bb3-48e45b799420"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/30dca6cd-f2be-4dcb-8596-924326ebf8c0"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/58896699-1f05-46c9-88cb-908318e71476"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/ea0d91b0-077b-4d72-81a7-d38d17742da6"
/>




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-11-24 17:49:24 -08:00
48627da0f9 feat: outbound voice call essentials (#12782)
- Enables outbound voice calls in voice channel . We are only caring
about wiring the logic to trigger outgoing calls to the call button
introduced in previous PRs. We will connect it to call component in
subsequent PRs

ref: #11602 

## Screens

<img width="2304" height="1202" alt="image"
src="https://github.com/user-attachments/assets/b91543a8-8d4e-4229-bd80-9727b42c7b0f"
/>

<img width="2304" height="1200" alt="image"
src="https://github.com/user-attachments/assets/1a1dad2a-8cb2-4aa2-9702-c062416556a7"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
2025-11-24 17:47:00 -08:00
Sivin VargheseandGitHub 6a712b7592 fix: Update Arcade embed aspect ratio (#12923) 2025-11-24 20:22:27 +05:30
e9c60aec04 feat: Add support for Langfuse LLM Tracing via OTEL (#12905)
This PR adds LLM instrumentation on langfuse for ai-editor feature

## Type of change
New feature (non-breaking change which adds functionality)

Needs langfuse account and env vars to be set

## How Has This Been Tested?

I configured personal langfuse credentials and instrumented the app,
traces can be seen in langfuse.
each conversation is one session. 
<img width="1683" height="714" alt="image"
src="https://github.com/user-attachments/assets/3fcba1c9-63cf-44b9-a355-fd6608691559"
/>
<img width="1446" height="172" alt="image"
src="https://github.com/user-attachments/assets/dfa6e98f-4741-4e04-9a9e-078d1f01e97b"
/>


## Checklist:

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

---------

Co-authored-by: aakashb95 <aakash@chatwoot.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-11-21 16:31:45 -08:00
Vishnu NarayananandGitHub a8e9acfae9 fix: shopify and leadsquared specs in ci (#12926)
fix: shopify and leadsquared specs in ci
2025-11-21 17:01:03 +05:30
Vishnu NarayananandGitHub f5908b0f8a fix: test failures due to parallelisation in ci (#12924) 2025-11-21 16:33:29 +05:30
117 changed files with 5207 additions and 1777 deletions
+1
View File
@@ -220,6 +220,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
# DD_TRACE_AGENT_URL=
# MaxMindDB API key to download GeoLite2 City database
# IP_LOOKUP_API_KEY=
+2
View File
@@ -99,3 +99,5 @@ CLAUDE.local.md
# Histoire deployment
.netlify
.histoire
.pnpm-store/*
local/
+1 -1
View File
@@ -39,7 +39,7 @@ exclude_patterns = [
"**/target/**",
"**/templates/**",
"**/testdata/**",
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
]
test_patterns = [
+5 -1
View File
@@ -7,6 +7,7 @@ plugins:
require:
- ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb
- ./rubocop/one_class_per_file.rb
Layout/LineLength:
Max: 150
@@ -87,7 +88,7 @@ Metrics/ModuleLength:
Rails/HelperInstanceVariable:
Exclude:
- enterprise/app/helpers/captain/chat_helper.rb
- 'enterprise/app/helpers/captain/tool_execution_helper.rb'
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -205,6 +206,9 @@ UseFromEmail:
CustomCopLocation:
Enabled: true
Style/OneClassPerFile:
Enabled: true
AllCops:
NewCops: enable
Exclude:
+5
View File
@@ -10,6 +10,9 @@
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
- **Run Project**: `overmind start -f Procfile.dev`
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
## Code Style
@@ -37,6 +40,8 @@
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
+4
View File
@@ -196,6 +196,10 @@ gem 'ai-agents', '>= 0.4.3'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm-schema'
# OpenTelemetry for LLM observability
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
gem 'shopify_api'
### Gems required only in specific deployment environments ###
+21
View File
@@ -625,6 +625,25 @@ GEM
faraday (>= 1.0, < 3)
multi_json (>= 1.0)
openssl (3.2.0)
opentelemetry-api (1.7.0)
opentelemetry-common (0.23.0)
opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.31.1)
google-protobuf (>= 3.18)
googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.10)
opentelemetry-semantic_conventions
opentelemetry-registry (0.4.0)
opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.10.0)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2)
opentelemetry-semantic_conventions
opentelemetry-semantic_conventions (1.36.0)
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.1)
@@ -1073,6 +1092,8 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
omniauth-saml
opensearch-ruby
opentelemetry-exporter-otlp
opentelemetry-sdk
pg
pg_search
pgvector
+2
View File
@@ -103,3 +103,5 @@ class ContactInboxBuilder
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
end
end
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
+3
View File
@@ -138,6 +138,7 @@ class Messages::MessageBuilder
private: @private,
sender: sender,
content_type: @params[:content_type],
content_attributes: content_attributes.presence,
items: @items,
in_reply_to: @in_reply_to,
echo_id: @params[:echo_id],
@@ -222,3 +223,5 @@ class Messages::MessageBuilder
})
end
end
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
+6
View File
@@ -47,6 +47,12 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/labels`);
}
initiateCall(contactId, inboxId) {
return axios.post(`${this.url}/${contactId}/call`, {
inbox_id: inboxId,
});
}
updateContactLabels(contactId, labels) {
return axios.post(`${this.url}/${contactId}/labels`, { labels });
}
@@ -57,6 +57,12 @@ class OpenAIAPI extends ApiClient {
content,
};
// Always include conversation_display_id when available for session tracking
if (conversationId) {
data.conversation_display_id = conversationId;
}
// For conversation-level events, only send conversation_display_id
if (this.conversation_events.includes(type)) {
data = {
conversation_display_id: conversationId,
@@ -102,6 +102,7 @@ const closeMobileSidebar = () => {
/>
<VoiceCallButton
:phone="selectedContact?.phoneNumber"
:contact-id="contactId"
:label="$t('CONTACT_PANEL.CALL')"
size="sm"
/>
@@ -1,15 +1,18 @@
<script setup>
import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
phone: { type: String, default: '' },
contactId: { type: [String, Number], required: true },
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
size: { type: String, default: 'sm' },
@@ -18,10 +21,17 @@ const props = defineProps({
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const dialogRef = ref(null);
const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const voiceInboxes = computed(() =>
(inboxesList.value || []).filter(
inbox => inbox.channel_type === INBOX_TYPES.VOICE
@@ -32,20 +42,51 @@ const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const dialogRef = ref(null);
const isInitiatingCall = computed(() => {
return contactsUiFlags.value?.isInitiatingCall || false;
});
const onClick = () => {
const navigateToConversation = conversationId => {
const accountId = route.params.accountId;
if (conversationId && accountId) {
const path = frontendURL(
conversationUrl({
accountId,
id: conversationId,
})
);
router.push({ path });
}
};
const startCall = async inboxId => {
if (isInitiatingCall.value) return;
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId,
inboxId,
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(response?.conversation_id);
} catch (error) {
const apiError = error?.message;
useAlert(apiError || t('CONTACT_PANEL.CALL_FAILED'));
}
};
const onClick = async () => {
if (voiceInboxes.value.length > 1) {
dialogRef.value?.open();
return;
}
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
const [inbox] = voiceInboxes.value;
await startCall(inbox.id);
};
const onPickInbox = () => {
// Placeholder until actual call wiring happens
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
const onPickInbox = async inbox => {
dialogRef.value?.close();
await startCall(inbox.id);
};
</script>
@@ -55,6 +96,8 @@ const onPickInbox = () => {
v-if="shouldRender"
v-tooltip.top-end="tooltipLabel || null"
v-bind="attrs"
:disabled="isInitiatingCall"
:is-loading="isInitiatingCall"
:label="label"
:icon="icon"
:size="size"
@@ -118,6 +118,7 @@ watch(
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
:message-type="formErrors.description ? 'error' : 'info'"
class="z-0"
/>
<div class="flex flex-col gap-2">
@@ -108,6 +108,7 @@ watch(
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
:message="formErrors.handoffMessage"
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
class="z-0"
/>
<Editor
@@ -116,6 +117,7 @@ watch(
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
:message="formErrors.resolutionMessage"
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
class="z-0"
/>
<Editor
@@ -126,6 +128,7 @@ watch(
:message="formErrors.instructions"
:max-length="20000"
:message-type="formErrors.instructions ? 'error' : 'info'"
class="z-0"
/>
<div class="flex flex-col gap-2">
@@ -1,41 +1,102 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import { useMessageContext } from '../provider.js';
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
const { contentAttributes } = useMessageContext();
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const SUBTEXT_MAP = {
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
};
const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { contentAttributes, messageType } = useMessageContext();
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status?.toString());
const status = computed(() => data.value?.status);
const direction = computed(() => data.value?.call_direction);
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
useVoiceCallStatus(status, direction);
const containerRingClass = computed(() => {
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const subtextKey = computed(() => {
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const iconName = computed(() => {
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
</script>
<template>
<BaseBubble class="p-0 border-none" hide-meta>
<div
class="flex overflow-hidden flex-col w-full max-w-xs bg-white rounded-lg border border-slate-100 text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
:class="containerRingClass"
>
<div class="flex overflow-hidden flex-col w-full max-w-xs">
<div class="flex gap-3 items-center p-3 w-full">
<div
class="flex justify-center items-center rounded-full size-10 shrink-0"
:class="bubbleIconBg"
:class="bgColor"
>
<span class="text-xl" :class="bubbleIconName" />
<Icon
class="size-5"
:icon="iconName"
:class="{
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
}"
/>
</div>
<div class="flex overflow-hidden flex-col flex-grow">
<span class="text-base font-medium truncate">{{ $t(labelKey) }}</span>
<span class="text-xs text-slate-500">{{ $t(subtextKey) }}</span>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
</span>
<span class="text-xs text-n-slate-11">
{{ $t(subtextKey) }}
</span>
</div>
</div>
</div>
@@ -73,3 +73,16 @@ export const MEDIA_TYPES = [
ATTACHMENT_TYPES.AUDIO,
ATTACHMENT_TYPES.IG_REEL,
];
export const VOICE_CALL_STATUS = {
IN_PROGRESS: 'in-progress',
RINGING: 'ringing',
COMPLETED: 'completed',
NO_ANSWER: 'no-answer',
FAILED: 'failed',
};
export const VOICE_CALL_DIRECTION = {
INBOUND: 'inbound',
OUTBOUND: 'outbound',
};
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useNumberFormatter } from 'shared/composables/useNumberFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -24,6 +25,7 @@ const props = defineProps({
});
const emit = defineEmits(['update:currentPage']);
const { t } = useI18n();
const { formatCompactNumber, formatFullNumber } = useNumberFormatter();
const totalPages = computed(() =>
Math.ceil(props.totalItems / props.itemsPerPage)
@@ -43,21 +45,27 @@ const changePage = newPage => {
};
const currentPageInformation = computed(() => {
const translationKey = props.currentPageInfo || 'PAGINATION_FOOTER.SHOWING';
return t(
props.currentPageInfo ? props.currentPageInfo : 'PAGINATION_FOOTER.SHOWING',
translationKey,
{
startItem: startItem.value,
endItem: endItem.value,
totalItems: props.totalItems,
}
startItem: formatFullNumber(startItem.value),
endItem: formatFullNumber(endItem.value),
totalItems: formatCompactNumber(props.totalItems),
},
Number(props.totalItems)
);
});
const pageInfo = computed(() => {
return t('PAGINATION_FOOTER.CURRENT_PAGE_INFO', {
currentPage: '',
totalPages: totalPages.value,
});
return t(
'PAGINATION_FOOTER.CURRENT_PAGE_INFO',
{
currentPage: '',
totalPages: formatCompactNumber(totalPages.value),
},
Number(totalPages.value)
);
});
</script>
@@ -91,9 +99,11 @@ const pageInfo = computed(() => {
/>
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
{{ currentPage }}
{{ formatFullNumber(currentPage) }}
</span>
<span class="truncate">
{{ pageInfo }}
</span>
<span class="truncate">{{ pageInfo }}</span>
</div>
<Button
icon="i-lucide-chevron-right"
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
@@ -14,6 +13,7 @@ import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
@@ -83,15 +83,10 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const callStatus = computed(
() => props.chat.additional_attributes?.call_status
);
const callDirection = computed(
() => props.chat.additional_attributes?.call_direction
);
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
useVoiceCallStatus(callStatus, callDirection);
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const inboxId = computed(() => props.chat.inbox_id);
@@ -317,20 +312,13 @@ const deleteConversation = () => {
>
{{ currentContact.name }}
</h4>
<div
v-if="callStatus"
<VoiceCallStatus
v-if="voiceCallData.status"
key="voice-status-row"
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<span
class="inline-block -mt-0.5 align-middle text-[16px] i-ph-phone-incoming"
:class="[voiceIconColor]"
/>
<span class="mx-1">
{{ $t(voiceLabelKey) }}
</span>
</div>
:status="voiceCallData.status"
:direction="voiceCallData.direction"
:message-preview-class="messagePreviewClass"
/>
<MessagePreview
v-else-if="lastMessageInChat"
key="message-preview"
@@ -1,450 +1,462 @@
<script>
import { ref, provide } from 'vue';
// composable
<script setup>
import {
ref,
computed,
watch,
onMounted,
onUnmounted,
nextTick,
provide,
getCurrentInstance,
} from 'vue';
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import {
useInbox,
INBOX_FEATURES,
INBOX_FEATURE_MAP,
} from 'dashboard/composables/useInbox';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
// components
import ReplyBox from './ReplyBox.vue';
import MessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
// stores and apis
import { mapGetters } from 'vuex';
// mixins
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
// utils
import { emitter } from 'shared/helpers/mitt';
import { getTypingUsersText } from '../../../helper/commons';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { LocalStorage } from 'shared/helpers/localStorage';
import {
filterDuplicateSourceMessages,
getReadMessages,
getUnreadMessages,
} from 'dashboard/helper/conversationHelper';
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export default {
components: {
MessageList,
ReplyBox,
Banner,
ConversationLabelSuggestion,
Spinner,
},
mixins: [inboxMixin],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig();
const store = useStore();
const route = useRoute();
const { t } = useI18n();
const instance = getCurrentInstance();
const { isEnterprise } = useConfig();
const keyboardEvents = {
Escape: {
action: () => {
isPopOutReplyBox.value = false;
},
},
};
const {
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
useKeyboardEvents(keyboardEvents);
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const isLoadingPrevious = ref(true);
const heightBeforeLoad = ref(null);
const scrollTopBeforeLoad = ref(null);
const conversationPanel = ref(null);
const hasUserScrolled = ref(false);
const isProgrammaticScroll = ref(false);
const messageSentSinceOpened = ref(false);
const labelSuggestions = ref([]);
const {
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
provide('contextMenuElementTarget', conversationPanelRef);
return {
isEnterprise,
isPopOutReplyBox,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
conversationPanelRef,
};
},
data() {
return {
isLoadingPrevious: true,
heightBeforeLoad: null,
conversationPanel: null,
hasUserScrolled: false,
isProgrammaticScroll: false,
messageSentSinceOpened: false,
labelSuggestions: [],
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
},
shouldShowLabelSuggestions() {
return (
this.isOpen &&
this.isEnterprise &&
this.isAIIntegrationEnabled &&
!this.messageSentSinceOpened
);
},
inboxId() {
return this.currentChat.inbox_id;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
typingUsersList() {
const userList = this.$store.getters[
'conversationTypingStatus/getUserList'
](this.currentChat.id);
return userList;
},
isAnyoneTyping() {
const userList = this.typingUsersList;
return userList.length !== 0;
},
typingUserNames() {
const userList = this.typingUsersList;
if (this.isAnyoneTyping) {
const [i18nKey, params] = getTypingUsersText(userList);
return this.$t(i18nKey, params);
}
return '';
},
getMessages() {
const messages = this.currentChat.messages || [];
if (this.isAWhatsAppChannel) {
return filterDuplicateSourceMessages(messages);
}
return messages;
},
readMessages() {
return getReadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
unReadMessages() {
return getUnreadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
shouldShowSpinner() {
return (
(this.currentChat && this.currentChat.dataFetched === undefined) ||
(!this.listLoadingStatus && this.isLoadingPrevious)
);
},
// Check there is a instagram inbox exists with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.inbox.instagram_id;
const { additional_attributes: additionalAttributes = {} } = this.inbox;
const instagramInbox =
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
instagramId
);
return (
this.inbox.channel_type === INBOX_TYPES.FB &&
additionalAttributes.type === 'instagram_direct_message' &&
instagramInbox
);
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
}
if (this.isAPIInbox) {
const { additional_attributes: additionalAttributes = {} } = this.inbox;
if (additionalAttributes) {
const {
agent_reply_time_window_message: agentReplyTimeWindowMessage,
agent_reply_time_window: agentReplyTimeWindow,
} = additionalAttributes;
return (
agentReplyTimeWindowMessage ||
this.$t('CONVERSATION.API_HOURS_WINDOW', {
hours: agentReplyTimeWindow,
})
);
}
return '';
}
return this.$t('CONVERSATION.CANNOT_REPLY');
},
replyWindowLink() {
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
return REPLY_POLICY.FACEBOOK;
}
if (this.isAWhatsAppCloudChannel) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (!this.isAPIInbox) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
return '';
},
replyWindowLinkText() {
if (
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAnInstagramChannel
) {
return this.$t('CONVERSATION.24_HOURS_WINDOW');
}
if (!this.isAPIInbox) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
}
return '';
},
unreadMessageCount() {
return this.currentChat.unread_count || 0;
},
unreadMessageLabel() {
const count =
this.unreadMessageCount > 9 ? '9+' : this.unreadMessageCount;
const label =
this.unreadMessageCount > 1
? 'CONVERSATION.UNREAD_MESSAGES'
: 'CONVERSATION.UNREAD_MESSAGE';
return `${count} ${this.$t(label)}`;
},
inboxSupportsReplyTo() {
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
const outgoing =
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
!this.is360DialogWhatsAppChannel;
return { incoming, outgoing };
},
},
watch: {
currentChat(newChat, oldChat) {
if (newChat.id === oldChat.id) {
return;
}
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.messageSentSinceOpened = false;
},
},
created() {
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a new message comes in, we refetch the label suggestions
emitter.on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
this.messageSentSinceOpened = true;
});
},
mounted() {
this.addScrollListener();
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
},
unmounted() {
this.removeBusListeners();
this.removeScrollListener();
},
methods: {
async fetchSuggestions() {
// start empty, this ensures that the label suggestions are not shown
this.labelSuggestions = [];
if (this.isLabelSuggestionDismissed()) {
return;
}
if (!this.isEnterprise) {
return;
}
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
if (!this.isLabelSuggestionFeatureEnabled) {
return;
}
this.labelSuggestions = await this.fetchLabelSuggestions({
conversationId: this.currentChat.id,
});
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
// so we use the nextTick method
this.$nextTick(() => {
// this param is added to route, telling the UI to navigate to the message
// it is triggered by the SCROLL_TO_MESSAGE method
// see setActiveChat on ConversationView.vue for more info
const { messageId } = this.$route.query;
// only trigger the scroll to bottom if the user has not scrolled
// and there's no active messageId that is selected in view
if (!messageId && !this.hasUserScrolled) {
this.scrollToBottom();
}
});
},
isLabelSuggestionDismissed() {
return LocalStorage.getFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
this.currentAccountId,
this.currentChat.id
);
},
fetchAllAttachmentsFromCurrentChat() {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
removeBusListeners() {
emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
},
onScrollToMessage({ messageId = '' } = {}) {
this.$nextTick(() => {
const messageElement = document.getElementById('message' + messageId);
if (messageElement) {
this.isProgrammaticScroll = true;
messageElement.scrollIntoView({ behavior: 'smooth' });
this.fetchPreviousMessages();
} else {
this.scrollToBottom();
}
});
this.makeMessagesRead();
},
addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel');
this.setScrollParams();
this.conversationPanel.addEventListener('scroll', this.handleScroll);
this.$nextTick(() => this.scrollToBottom());
this.isLoadingPrevious = false;
},
removeScrollListener() {
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
},
scrollToBottom() {
this.isProgrammaticScroll = true;
let relevantMessages = [];
// label suggestions are not part of the messages list
// so we need to handle them separately
let labelSuggestions =
this.conversationPanel.querySelector('.label-suggestion');
// if there are unread messages, scroll to the first unread message
if (this.unreadMessageCount > 0) {
// capturing only the unread messages
relevantMessages =
this.conversationPanel.querySelectorAll('.message--unread');
} else if (labelSuggestions) {
// when scrolling to the bottom, the label suggestions is below the last message
// so we scroll there if there are no unread messages
// Unread messages always take the highest priority
relevantMessages = [labelSuggestions];
} else {
// if there are no unread messages or label suggestion, scroll to the last message
// capturing last message from the messages list
relevantMessages = Array.from(
this.conversationPanel.querySelectorAll('.message--read')
).slice(-1);
}
this.conversationPanel.scrollTop = calculateScrollTop(
this.conversationPanel.scrollHeight,
this.$el.scrollHeight,
relevantMessages
);
},
setScrollParams() {
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
},
async fetchPreviousMessages(scrollTop = 0) {
this.setScrollParams();
const shouldLoadMoreMessages =
this.currentChat.dataFetched === true &&
!this.listLoadingStatus &&
!this.isLoadingPrevious;
if (
scrollTop < 100 &&
!this.isLoadingPrevious &&
shouldLoadMoreMessages
) {
this.isLoadingPrevious = true;
try {
await this.$store.dispatch('fetchPreviousMessages', {
conversationId: this.currentChat.id,
before: this.currentChat.messages[0].id,
});
const heightDifference =
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
this.conversationPanel.scrollTop =
this.scrollTopBeforeLoad + heightDifference;
this.setScrollParams();
} catch (error) {
// Ignore Error
} finally {
this.isLoadingPrevious = false;
}
}
},
handleScroll(e) {
if (this.isProgrammaticScroll) {
// Reset the flag
this.isProgrammaticScroll = false;
this.hasUserScrolled = false;
} else {
this.hasUserScrolled = true;
}
emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.fetchPreviousMessages(e.target.scrollTop);
},
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
async handleMessageRetry(message) {
if (!message) return;
const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload);
const keyboardEvents = {
Escape: {
action: () => {
isPopOutReplyBox.value = false;
},
},
};
useKeyboardEvents(keyboardEvents);
provide('contextMenuElementTarget', conversationPanelRef);
const currentChat = useMapGetter('getSelectedChat');
const currentUserId = useMapGetter('getCurrentUserID');
const listLoadingStatus = useMapGetter('getAllMessagesLoaded');
const currentAccountId = useMapGetter('getCurrentAccountId');
const instagramInboxByIdGetter = useMapGetter(
'inboxes/getInstagramInboxByInstagramId'
);
const inboxGetter = useMapGetter('inboxes/getInbox');
const typingUsersListGetter = useMapGetter(
'conversationTypingStatus/getUserList'
);
const isOpen = computed(() => {
return currentChat.value?.status === wootConstants.STATUS_TYPE.OPEN;
});
const shouldShowLabelSuggestions = computed(() => {
return (
isOpen.value &&
isEnterprise &&
isAIIntegrationEnabled.value &&
!messageSentSinceOpened.value
);
});
const inboxId = computed(() => {
return currentChat.value?.inbox_id;
});
const inbox = computed(() => {
return inboxGetter.value(inboxId.value);
});
const {
isAPIInbox,
isAFacebookInbox,
isAWhatsAppChannel,
isAWhatsAppCloudChannel,
isAnInstagramChannel,
is360DialogWhatsAppChannel,
isAnEmailChannel,
} = useInbox();
const inboxHasFeature = feature => {
const channelType = inbox.value?.channel_type;
return INBOX_FEATURE_MAP[feature]?.includes(channelType) ?? false;
};
const typingUsersList = computed(() => {
const userList = typingUsersListGetter.value(currentChat.value.id);
return userList;
});
const isAnyoneTyping = computed(() => {
const userList = typingUsersList.value;
return userList.length !== 0;
});
const typingUserNames = computed(() => {
const userList = typingUsersList.value;
if (isAnyoneTyping.value) {
const [i18nKey, params] = getTypingUsersText(userList);
return t(i18nKey, params);
}
return '';
});
const getMessages = computed(() => {
const messages = currentChat.value.messages || [];
if (isAWhatsAppChannel.value) {
return filterDuplicateSourceMessages(messages);
}
return messages;
});
const unReadMessages = computed(() => {
return getUnreadMessages(
getMessages.value,
currentChat.value.agent_last_seen_at
);
});
const shouldShowSpinner = computed(() => {
return (
(currentChat.value && currentChat.value.dataFetched === undefined) ||
(!listLoadingStatus.value && isLoadingPrevious.value)
);
});
// Check there is a instagram inbox exists with the same instagram_id
const hasDuplicateInstagramInbox = computed(() => {
const instagramId = inbox.value.instagram_id;
const { additional_attributes: additionalAttributes = {} } = inbox.value;
const instagramInbox = instagramInboxByIdGetter.value(instagramId);
return (
inbox.value.channel_type === INBOX_TYPES.FB &&
additionalAttributes.type === 'instagram_direct_message' &&
instagramInbox
);
});
const replyWindowBannerMessage = computed(() => {
if (isAWhatsAppChannel.value) {
return t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
}
if (isAPIInbox.value) {
const { additional_attributes: additionalAttributes = {} } = inbox.value;
if (additionalAttributes) {
const {
agent_reply_time_window_message: agentReplyTimeWindowMessage,
agent_reply_time_window: agentReplyTimeWindow,
} = additionalAttributes;
return (
agentReplyTimeWindowMessage ||
t('CONVERSATION.API_HOURS_WINDOW', {
hours: agentReplyTimeWindow,
})
);
}
return '';
}
return t('CONVERSATION.CANNOT_REPLY');
});
const replyWindowLink = computed(() => {
if (isAFacebookInbox.value || isAnInstagramChannel.value) {
return REPLY_POLICY.FACEBOOK;
}
if (isAWhatsAppCloudChannel.value) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (!isAPIInbox.value) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
return '';
});
const replyWindowLinkText = computed(() => {
if (
isAWhatsAppChannel.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value
) {
return t('CONVERSATION.24_HOURS_WINDOW');
}
if (!isAPIInbox.value) {
return t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
}
return '';
});
const unreadMessageCount = computed(() => {
return currentChat.value.unread_count || 0;
});
const unreadMessageLabel = computed(() => {
const count = unreadMessageCount.value > 9 ? '9+' : unreadMessageCount.value;
const label =
unreadMessageCount.value > 1
? 'CONVERSATION.UNREAD_MESSAGES'
: 'CONVERSATION.UNREAD_MESSAGE';
return `${count} ${t(label)}`;
});
const inboxSupportsReplyTo = computed(() => {
const incoming = inboxHasFeature(INBOX_FEATURES.REPLY_TO);
const outgoing =
inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
!is360DialogWhatsAppChannel.value;
return { incoming, outgoing };
});
const isLabelSuggestionDismissed = () => {
return LocalStorage.getFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
currentAccountId.value,
currentChat.value.id
);
};
const fetchAllAttachmentsFromCurrentChat = () => {
store.dispatch('fetchAllAttachments', currentChat.value.id);
};
const makeMessagesRead = () => {
store.dispatch('markMessagesRead', { id: currentChat.value.id });
};
const setScrollParams = () => {
heightBeforeLoad.value = conversationPanel.value.scrollHeight;
scrollTopBeforeLoad.value = conversationPanel.value.scrollTop;
};
const scrollToBottom = () => {
isProgrammaticScroll.value = true;
let relevantMessages = [];
// label suggestions are not part of the messages list
// so we need to handle them separately
let labelSuggestionsEl =
conversationPanel.value.querySelector('.label-suggestion');
// if there are unread messages, scroll to the first unread message
if (unreadMessageCount.value > 0) {
// capturing only the unread messages
relevantMessages =
conversationPanel.value.querySelectorAll('.message--unread');
} else if (labelSuggestionsEl) {
// when scrolling to the bottom, the label suggestions is below the last message
// so we scroll there if there are no unread messages
// Unread messages always take the highest priority
relevantMessages = [labelSuggestionsEl];
} else {
// if there are no unread messages or label suggestion, scroll to the last message
// capturing last message from the messages list
relevantMessages = Array.from(
conversationPanel.value.querySelectorAll('.message--read')
).slice(-1);
}
conversationPanel.value.scrollTop = calculateScrollTop(
conversationPanel.value.scrollHeight,
instance.proxy.$el.scrollHeight,
relevantMessages
);
};
const fetchPreviousMessages = async (scrollTop = 0) => {
setScrollParams();
const shouldLoadMoreMessages =
currentChat.value.dataFetched === true &&
!listLoadingStatus.value &&
!isLoadingPrevious.value;
if (scrollTop < 100 && !isLoadingPrevious.value && shouldLoadMoreMessages) {
isLoadingPrevious.value = true;
try {
await store.dispatch('fetchPreviousMessages', {
conversationId: currentChat.value.id,
before: currentChat.value.messages[0].id,
});
const heightDifference =
conversationPanel.value.scrollHeight - heightBeforeLoad.value;
conversationPanel.value.scrollTop =
scrollTopBeforeLoad.value + heightDifference;
setScrollParams();
} catch (error) {
// Ignore Error
} finally {
isLoadingPrevious.value = false;
}
}
};
const onScrollToMessage = ({ messageId = '' } = {}) => {
nextTick(() => {
const messageElement = document.getElementById('message' + messageId);
if (messageElement) {
isProgrammaticScroll.value = true;
messageElement.scrollIntoView({ behavior: 'smooth' });
fetchPreviousMessages();
} else {
scrollToBottom();
}
});
makeMessagesRead();
};
const handleScroll = e => {
if (isProgrammaticScroll.value) {
// Reset the flag
isProgrammaticScroll.value = false;
hasUserScrolled.value = false;
} else {
hasUserScrolled.value = true;
}
emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
fetchPreviousMessages(e.target.scrollTop);
};
const addScrollListener = () => {
conversationPanel.value = instance.proxy.$el.querySelector(
'.conversation-panel'
);
setScrollParams();
conversationPanel.value.addEventListener('scroll', handleScroll);
nextTick(() => scrollToBottom());
isLoadingPrevious.value = false;
};
const removeScrollListener = () => {
conversationPanel.value.removeEventListener('scroll', handleScroll);
};
const removeBusListeners = () => {
emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, onScrollToMessage);
};
const fetchSuggestions = async () => {
// start empty, this ensures that the label suggestions are not shown
labelSuggestions.value = [];
if (isLabelSuggestionDismissed()) {
return;
}
if (!isEnterprise) {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = currentChat.value?.labels || [];
if (existingLabels.length > 0) return;
// method available in mixin, need to ensure that integrations are present
await fetchIntegrationsIfRequired();
if (!isLabelSuggestionFeatureEnabled.value) {
return;
}
labelSuggestions.value = await fetchLabelSuggestions({
conversationId: currentChat.value.id,
});
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
// so we use the nextTick method
nextTick(() => {
// this param is added to route, telling the UI to navigate to the message
// it is triggered by the SCROLL_TO_MESSAGE method
// see setActiveChat on ConversationView.vue for more info
const { messageId } = route.query;
// only trigger the scroll to bottom if the user has not scrolled
// and there's no active messageId that is selected in view
if (!messageId && !hasUserScrolled.value) {
scrollToBottom();
}
});
};
const handleMessageRetry = async message => {
if (!message) return;
const payload = useSnakeCase(message);
await store.dispatch('sendMessageWithData', payload);
};
watch(currentChat, (newChat, oldChat) => {
if (newChat.id === oldChat.id) {
return;
}
fetchAllAttachmentsFromCurrentChat();
fetchSuggestions();
messageSentSinceOpened.value = false;
});
onMounted(() => {
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, onScrollToMessage);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
messageSentSinceOpened.value = true;
});
addScrollListener();
fetchAllAttachmentsFromCurrentChat();
fetchSuggestions();
});
onUnmounted(() => {
removeBusListeners();
removeScrollListener();
});
</script>
<template>
@@ -461,7 +473,7 @@ export default {
v-else-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
:banner-message="t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<MessageList
ref="conversationPanelRef"
@@ -174,19 +174,31 @@ export default {
return false;
},
showWhatsappTemplates() {
return this.isAWhatsAppCloudChannel && !this.isPrivate;
// We support templates for API channels if someone updates templates manually via API
// That's why we don't explicitly check for channel type here
const templates = this.$store.getters['inboxes/getWhatsAppTemplates'](
this.inboxId
);
return !!(templates && templates.length) && !this.isPrivate;
},
showContentTemplates() {
return this.isATwilioWhatsAppChannel && !this.isPrivate;
},
isPrivate() {
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
if (
this.currentChat.can_reply ||
this.isAWhatsAppChannel ||
this.isAPIInbox
) {
return this.isOnPrivateNote;
}
return true;
},
isReplyRestricted() {
return !this.currentChat?.can_reply && !this.isAWhatsAppChannel;
return (
!this.currentChat?.can_reply &&
!(this.isAWhatsAppChannel || this.isAPIInbox)
);
},
inboxId() {
return this.currentChat.inbox_id;
@@ -442,7 +454,7 @@ export default {
return;
}
if (canReply || this.isAWhatsAppChannel) {
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
this.replyType = REPLY_EDITOR_MODES.REPLY;
} else {
this.replyType = REPLY_EDITOR_MODES.NOTE;
@@ -861,7 +873,8 @@ export default {
this.$store.dispatch('draftMessages/setReplyEditorMode', {
mode,
});
if (canReply || this.isAWhatsAppChannel) this.replyType = mode;
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
@@ -0,0 +1,77 @@
<script setup>
import { computed } from 'vue';
import {
VOICE_CALL_STATUS,
VOICE_CALL_DIRECTION,
} from 'dashboard/components-next/message/constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: { type: String, default: '' },
direction: { type: String, default: '' },
messagePreviewClass: { type: [String, Array, Object], default: '' },
});
const LABEL_KEYS = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
if (props.status === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const iconName = computed(() => {
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const statusColor = computed(
() => COLOR_MAP[props.status] || 'text-n-slate-11'
);
</script>
<template>
<div
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<Icon
class="inline-block -mt-0.5 align-middle size-4"
:icon="iconName"
:class="statusColor"
/>
<span class="mx-1" :class="statusColor">
{{ $t(labelKey) }}
</span>
</div>
</template>
@@ -1,161 +0,0 @@
import { computed, unref } from 'vue';
const CALL_STATUSES = {
IN_PROGRESS: 'in-progress',
RINGING: 'ringing',
NO_ANSWER: 'no-answer',
BUSY: 'busy',
FAILED: 'failed',
COMPLETED: 'completed',
CANCELED: 'canceled',
};
const CALL_DIRECTIONS = {
INBOUND: 'inbound',
OUTBOUND: 'outbound',
};
/**
* Composable for handling voice call status display logic
* @param {Ref|string} statusRef - Call status (ringing, in-progress, etc.)
* @param {Ref|string} directionRef - Call direction (inbound, outbound)
* @returns {Object} UI properties for displaying call status
*/
export function useVoiceCallStatus(statusRef, directionRef) {
const status = computed(() => unref(statusRef)?.toString());
const direction = computed(() => unref(directionRef)?.toString());
// Status group helpers
const isFailedStatus = computed(() =>
[
CALL_STATUSES.NO_ANSWER,
CALL_STATUSES.BUSY,
CALL_STATUSES.FAILED,
].includes(status.value)
);
const isEndedStatus = computed(() =>
[CALL_STATUSES.COMPLETED, CALL_STATUSES.CANCELED].includes(status.value)
);
const isOutbound = computed(
() => direction.value === CALL_DIRECTIONS.OUTBOUND
);
const labelKey = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS';
}
if (s === CALL_STATUSES.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
if (s === CALL_STATUSES.NO_ANSWER) {
return 'CONVERSATION.VOICE_CALL.MISSED_CALL';
}
if (isFailedStatus.value) {
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
}
if (isEndedStatus.value) {
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
}
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const subtextKey = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.RINGING) {
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
}
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
}
if (isFailedStatus.value) {
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
}
if (isEndedStatus.value) {
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
}
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const bubbleIconName = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'i-ph-phone-outgoing-fill'
: 'i-ph-phone-incoming-fill';
}
if (isFailedStatus.value) {
return 'i-ph-phone-x-fill';
}
// For ringing/completed/canceled show direction when possible
return isOutbound.value
? 'i-ph-phone-outgoing-fill'
: 'i-ph-phone-incoming-fill';
});
const bubbleIconBg = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return 'bg-n-teal-9';
}
if (isFailedStatus.value) {
return 'bg-n-ruby-9';
}
if (isEndedStatus.value) {
return 'bg-n-slate-11';
}
// default (e.g., ringing)
return 'bg-n-teal-9 animate-pulse';
});
const listIconColor = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS || s === CALL_STATUSES.RINGING) {
return 'text-n-teal-9';
}
if (isFailedStatus.value) {
return 'text-n-ruby-9';
}
if (isEndedStatus.value) {
return 'text-n-slate-11';
}
return 'text-n-teal-9';
});
return {
status,
labelKey,
subtextKey,
bubbleIconName,
bubbleIconBg,
listIconColor,
};
}
@@ -26,7 +26,7 @@
},
"COMPANIES_LAYOUT": {
"PAGINATION_FOOTER": {
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} companies"
"SHOWING": "Showing {startItem} {endItem} of {totalItems} company | Showing {startItem} {endItem} of {totalItems} companies"
}
}
}
@@ -1,7 +1,7 @@
{
"PAGINATION_FOOTER": {
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} items",
"CURRENT_PAGE_INFO": "{currentPage} of {totalPages} pages"
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} item | Showing {startItem} - {endItem} of {totalItems} items",
"CURRENT_PAGE_INFO": "{currentPage} of {totalPages} page | {currentPage} of {totalPages} pages"
},
"COMBOBOX": {
"PLACEHOLDER": "Select an option...",
@@ -18,7 +18,8 @@
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "New message",
"CALL": "Call",
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
"CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
},
@@ -376,7 +377,7 @@
}
},
"PAGINATION_FOOTER": {
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contacts"
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contact | Showing {startItem} - {endItem} of {totalItems} contacts"
},
"FILTER": {
"NAME": "Name",
@@ -13,6 +13,8 @@ import InboxPageEmptyState from 'dashboard/components-next/captain/pageComponent
const store = useStore();
const dialogType = ref('');
const route = useRoute();
const assistantId = computed(() => route.params.assistantId);
const assistantUiFlags = useMapGetter('captainAssistants/getUIFlags');
const uiFlags = useMapGetter('captainInboxes/getUIFlags');
const isFetchingAssistant = computed(() => assistantUiFlags.value.fetchingItem);
@@ -47,10 +49,9 @@ const handleCreateClose = () => {
selectedInbox.value = null;
};
const assistantId = Number(route.params.assistantId);
onMounted(() =>
store.dispatch('captainInboxes/get', {
assistantId: assistantId,
assistantId: assistantId.value,
})
);
</script>
@@ -34,7 +34,7 @@ const selectedResponse = ref(null);
const deleteDialog = ref(null);
const bulkDeleteDialog = ref(null);
const selectedAssistantId = Number(route.params.assistantId);
const selectedAssistantId = computed(() => route.params.assistantId);
const dialogType = ref('');
const searchQuery = ref('');
const { t } = useI18n();
@@ -45,7 +45,7 @@ const backUrl = computed(() => ({
name: 'captain_assistants_responses_index',
params: {
accountId: route.params.accountId,
assistantId: selectedAssistantId,
assistantId: selectedAssistantId.value,
},
}));
@@ -125,8 +125,8 @@ const updateURLWithFilters = (page, search) => {
const fetchResponses = (page = 1) => {
const filterParams = { page, status: 'pending' };
if (selectedAssistantId) {
filterParams.assistantId = selectedAssistantId;
if (selectedAssistantId.value) {
filterParams.assistantId = selectedAssistantId.value;
}
if (searchQuery.value) {
filterParams.search = searchQuery.value;
@@ -1,11 +1,10 @@
<script setup>
import { ref, computed, onMounted, reactive } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { debounce } from '@chatwoot/utils';
import { useCompaniesStore } from 'dashboard/stores/companies';
import CompaniesListLayout from 'dashboard/components-next/Companies/CompaniesListLayout.vue';
import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue';
@@ -13,13 +12,18 @@ import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/Com
const DEFAULT_SORT_FIELD = 'created_at';
const DEBOUNCE_DELAY = 300;
const store = useStore();
const companiesStore = useCompaniesStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { updateUISettings, uiSettings } = useUISettings();
const companies = computed(() => companiesStore.getCompaniesList);
const meta = computed(() => companiesStore.getMeta);
const uiFlags = computed(() => companiesStore.getUIFlags);
const searchQuery = computed(() => route.query?.search || '');
const searchValue = ref(searchQuery.value);
const pageNumber = computed(() => Number(route.query?.page) || 1);
@@ -46,10 +50,6 @@ const sortState = reactive({
const activeSort = computed(() => sortState.activeSort);
const activeOrdering = computed(() => sortState.activeOrdering);
const companies = useMapGetter('companies/getCompaniesList');
const meta = useMapGetter('companies/getMeta');
const uiFlags = useMapGetter('companies/getUIFlags');
const isFetchingList = computed(() => uiFlags.value.fetchingList);
const buildSortAttr = () =>
@@ -89,13 +89,13 @@ const fetchCompanies = async (page, search, sort) => {
}
if (currentSearch) {
await store.dispatch('companies/search', {
await companiesStore.search({
search: currentSearch,
page: currentPage,
sort: currentSort,
});
} else {
await store.dispatch('companies/get', {
await companiesStore.get({
page: currentPage,
sort: currentSort,
});
@@ -282,6 +282,7 @@ export default {
</ComposeConversation>
<VoiceCallButton
:phone="contact.phone_number"
:contact-id="contact.id"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
@@ -217,7 +217,7 @@ onMounted(handleBillingPageLogic);
solid
slate
icon="i-lucide-life-buoy"
@open="onToggleChatWindow"
@click="onToggleChatWindow"
>
{{ $t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT') }}
</ButtonV4>
@@ -52,11 +52,12 @@ const dayMenuItemConfigs = computed(() => [
},
]);
const resolvedLocale = computed(
() =>
const resolvedLocale = computed(() => {
const currentLocale =
locale.value ||
(typeof navigator !== 'undefined' ? navigator.language : 'en')
);
(typeof navigator !== 'undefined' ? navigator.language : 'en');
return currentLocale.replace('_', '-');
});
const monthFormatter = computed(
() =>
@@ -1,5 +1,5 @@
import CaptainAssistantAPI from 'dashboard/api/captain/assistant';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
export default createStore({
name: 'CaptainAssistant',
@@ -1,5 +1,5 @@
import CaptainBulkActionsAPI from 'dashboard/api/captain/bulkActions';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
@@ -1,5 +1,5 @@
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
export default createStore({
name: 'CopilotMessages',
@@ -1,5 +1,5 @@
import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
export default createStore({
name: 'CopilotThreads',
@@ -1,5 +1,5 @@
import CaptainCustomTools from 'dashboard/api/captain/customTools';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
@@ -1,5 +1,5 @@
import CaptainDocumentAPI from 'dashboard/api/captain/document';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
export default createStore({
name: 'CaptainDocument',
@@ -1,5 +1,5 @@
import CaptainInboxes from 'dashboard/api/captain/inboxes';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
@@ -1,5 +1,5 @@
import CaptainResponseAPI from 'dashboard/api/captain/response';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
@@ -1,5 +1,5 @@
import CaptainScenarios from 'dashboard/api/captain/scenarios';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
@@ -1,94 +0,0 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import {
createRecord,
deleteRecord,
getRecords,
showRecord,
updateRecord,
} from './storeFactoryHelper';
export const generateMutationTypes = name => {
const capitalizedName = name.toUpperCase();
return {
SET_UI_FLAG: `SET_${capitalizedName}_UI_FLAG`,
SET: `SET_${capitalizedName}`,
ADD: `ADD_${capitalizedName}`,
EDIT: `EDIT_${capitalizedName}`,
DELETE: `DELETE_${capitalizedName}`,
SET_META: `SET_${capitalizedName}_META`,
UPSERT: `UPSERT_${capitalizedName}`,
};
};
export const createInitialState = () => ({
records: [],
meta: {},
uiFlags: {
fetchingList: false,
fetchingItem: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
});
export const createGetters = () => ({
getRecords: state => state.records.sort((r1, r2) => r2.id - r1.id),
getRecord: state => id =>
state.records.find(record => record.id === Number(id)) || {},
getUIFlags: state => state.uiFlags,
getMeta: state => state.meta,
});
export const createMutations = mutationTypes => ({
[mutationTypes.SET_UI_FLAG](state, data) {
state.uiFlags = {
...state.uiFlags,
...data,
};
},
[mutationTypes.SET_META](state, meta) {
state.meta = {
...state.meta,
totalCount: Number(meta.total_count),
page: Number(meta.page),
};
},
[mutationTypes.SET]: MutationHelpers.set,
[mutationTypes.ADD]: MutationHelpers.create,
[mutationTypes.EDIT]: MutationHelpers.update,
[mutationTypes.DELETE]: MutationHelpers.destroy,
[mutationTypes.UPSERT]: MutationHelpers.setSingleRecord,
});
export const createCrudActions = (API, mutationTypes) => ({
get: getRecords(mutationTypes, API),
show: showRecord(mutationTypes, API),
create: createRecord(mutationTypes, API),
update: updateRecord(mutationTypes, API),
delete: deleteRecord(mutationTypes, API),
});
export const createStore = options => {
const { name, API, actions, getters, mutations } = options;
const mutationTypes = generateMutationTypes(name);
const customActions = actions ? actions(mutationTypes) : {};
return {
namespaced: true,
state: createInitialState(),
getters: {
...createGetters(),
...(getters || {}),
},
mutations: {
...createMutations(mutationTypes),
...(mutations || {}),
},
actions: {
...createCrudActions(API, mutationTypes),
...customActions,
},
};
};
@@ -1,380 +0,0 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import {
generateMutationTypes,
createInitialState,
createGetters,
createMutations,
createCrudActions,
createStore,
} from './storeFactory';
vi.mock('dashboard/store/utils/api', () => ({
throwErrorMessage: vi.fn(),
}));
vi.mock('shared/helpers/vuex/mutationHelpers', () => ({
set: vi.fn(),
create: vi.fn(),
update: vi.fn(),
destroy: vi.fn(),
setSingleRecord: vi.fn(),
}));
describe('storeFactory', () => {
describe('generateMutationTypes', () => {
it('generates correct mutation types with capitalized name', () => {
const result = generateMutationTypes('test');
expect(result).toEqual({
SET_UI_FLAG: 'SET_TEST_UI_FLAG',
SET: 'SET_TEST',
ADD: 'ADD_TEST',
EDIT: 'EDIT_TEST',
DELETE: 'DELETE_TEST',
SET_META: 'SET_TEST_META',
UPSERT: 'UPSERT_TEST',
});
});
});
describe('createInitialState', () => {
it('returns the correct initial state structure', () => {
const result = createInitialState();
expect(result).toEqual({
records: [],
meta: {},
uiFlags: {
fetchingList: false,
fetchingItem: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
});
});
});
describe('createGetters', () => {
it('returns getters with correct implementations', () => {
const getters = createGetters();
const state = {
records: [{ id: 2 }, { id: 1 }, { id: 3 }],
uiFlags: { fetchingList: true },
meta: { totalCount: 10, page: 1 },
};
expect(getters.getRecords(state)).toEqual([
{ id: 3 },
{ id: 2 },
{ id: 1 },
]);
expect(getters.getRecord(state)(2)).toEqual({ id: 2 });
expect(getters.getRecord(state)(4)).toEqual({});
expect(getters.getUIFlags(state)).toEqual({
fetchingList: true,
});
expect(getters.getMeta(state)).toEqual({
totalCount: 10,
page: 1,
});
});
});
describe('createMutations', () => {
it('creates mutations with correct implementations', () => {
const mutationTypes = generateMutationTypes('test');
const mutations = createMutations(mutationTypes);
const state = { uiFlags: { fetchingList: false } };
mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true });
expect(state.uiFlags).toEqual({ fetchingList: true });
const metaState = { meta: {} };
mutations[mutationTypes.SET_META](metaState, {
total_count: '10',
page: '2',
});
expect(metaState.meta).toEqual({ totalCount: 10, page: 2 });
expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set);
expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create);
expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update);
expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy);
expect(mutations[mutationTypes.UPSERT]).toBe(
MutationHelpers.setSingleRecord
);
});
});
describe('createCrudActions', () => {
let API;
let commit;
let mutationTypes;
let actions;
beforeEach(() => {
API = {
get: vi.fn(),
show: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
commit = vi.fn();
mutationTypes = generateMutationTypes('test');
actions = createCrudActions(API, mutationTypes);
});
describe('get action', () => {
it('handles successful API response', async () => {
const payload = [{ id: 1 }];
const meta = { total_count: 10, page: 1 };
API.get.mockResolvedValue({ data: { payload, meta } });
const result = await actions.get({ commit });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: false,
});
expect(result).toEqual(payload);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.get.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.get({ commit });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('show action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Test' };
API.show.mockResolvedValue({ data });
const result = await actions.show({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.show.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.show({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('create action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Test' };
API.create.mockResolvedValue({ data });
const result = await actions.create({ commit }, { name: 'Test' });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.create.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.create({ commit }, { name: 'Test' });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('update action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Updated' };
API.update.mockResolvedValue({ data });
const result = await actions.update(
{ commit },
{ id: 1, name: 'Updated' }
);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: true,
});
expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.update.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.update(
{ commit },
{ id: 1, name: 'Updated' }
);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('delete action', () => {
it('handles successful API response', async () => {
API.delete.mockResolvedValue({});
const result = await actions.delete({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: true,
});
expect(API.delete).toHaveBeenCalledWith(1);
expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: false,
});
expect(result).toEqual(1);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.delete.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.delete({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
});
describe('createStore', () => {
it('creates a complete store with default options', () => {
const API = {};
const store = createStore({ name: 'test', API });
expect(store.namespaced).toBe(true);
expect(store.state).toEqual(createInitialState());
expect(Object.keys(store.getters)).toEqual([
'getRecords',
'getRecord',
'getUIFlags',
'getMeta',
]);
expect(Object.keys(store.mutations)).toEqual([
'SET_TEST_UI_FLAG',
'SET_TEST_META',
'SET_TEST',
'ADD_TEST',
'EDIT_TEST',
'DELETE_TEST',
'UPSERT_TEST',
]);
expect(Object.keys(store.actions)).toEqual([
'get',
'show',
'create',
'update',
'delete',
]);
});
it('creates a store with custom actions and getters', () => {
const API = {};
const customGetters = { customGetter: () => 'custom' };
const customActions = () => ({
customAction: () => 'custom',
});
const store = createStore({
name: 'test',
API,
getters: customGetters,
actions: customActions,
});
expect(store.getters).toHaveProperty('customGetter');
expect(store.actions).toHaveProperty('customAction');
expect(Object.keys(store.getters)).toEqual([
'getRecords',
'getRecord',
'getUIFlags',
'getMeta',
'customGetter',
]);
expect(Object.keys(store.actions)).toEqual([
'get',
'show',
'create',
'update',
'delete',
'customAction',
]);
});
});
});
@@ -1,77 +0,0 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
export const getRecords =
(mutationTypes, API) =>
async ({ commit }, params = {}) => {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
try {
const response = await API.get(params);
commit(mutationTypes.SET, response.data.payload);
commit(mutationTypes.SET_META, response.data.meta);
return response.data.payload;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
}
};
export const showRecord =
(mutationTypes, API) =>
async ({ commit }, id) => {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
try {
const response = await API.show(id);
commit(mutationTypes.UPSERT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
}
};
export const createRecord =
(mutationTypes, API) =>
async ({ commit }, dataObj) => {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
try {
const response = await API.create(dataObj);
commit(mutationTypes.UPSERT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
}
};
export const updateRecord =
(mutationTypes, API) =>
async ({ commit }, { id, ...updateObj }) => {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
try {
const response = await API.update(id, updateObj);
commit(mutationTypes.EDIT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
}
};
export const deleteRecord =
(mutationTypes, API) =>
async ({ commit }, id) => {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
try {
await API.delete(id);
commit(mutationTypes.DELETE, id);
return id;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
}
};
@@ -1,4 +1,4 @@
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
import CaptainToolsAPI from '../../api/captain/tools';
import { throwErrorMessage } from 'dashboard/store/utils/api';
-2
View File
@@ -14,7 +14,6 @@ import bulkActions from './modules/bulkActions';
import campaigns from './modules/campaigns';
import cannedResponse from './modules/cannedResponse';
import categories from './modules/helpCenterCategories';
import companies from './modules/companies';
import contactConversations from './modules/contactConversations';
import contactLabels from './modules/contactLabels';
import contactNotes from './modules/contactNotes';
@@ -78,7 +77,6 @@ export default createStore({
campaigns,
cannedResponse,
categories,
companies,
contactConversations,
contactLabels,
contactNotes,
@@ -1,29 +0,0 @@
import CompanyAPI from 'dashboard/api/companies';
import { createStore } from 'dashboard/store/captain/storeFactory';
import camelcaseKeys from 'camelcase-keys';
export default createStore({
name: 'Company',
API: CompanyAPI,
getters: {
getCompaniesList: state => {
return camelcaseKeys(state.records, { deep: true });
},
},
actions: mutationTypes => ({
search: async ({ commit }, { search, page, sort }) => {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
try {
const {
data: { payload, meta },
} = await CompanyAPI.search(search, page, sort);
commit(mutationTypes.SET, payload);
commit(mutationTypes.SET_META, meta);
} catch (error) {
// Error
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
}
},
}),
});
@@ -302,4 +302,22 @@ export const actions = {
clearContactFilters({ commit }) {
commit(types.CLEAR_CONTACT_FILTERS);
},
initiateCall: async ({ commit }, { contactId, inboxId }) => {
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true });
try {
const response = await ContactAPI.initiateCall(contactId, inboxId);
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
return response.data;
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
if (error.response?.data?.message) {
throw new ExceptionWithMessage(error.response.data.message);
} else if (error.response?.data?.error) {
throw new ExceptionWithMessage(error.response.data.error);
} else {
throw new Error(error);
}
}
},
};
@@ -17,6 +17,7 @@ const state = {
isDeleting: false,
isExporting: false,
isImporting: false,
isInitiatingCall: false,
},
sortOrder: [],
appliedFilters: [],
@@ -194,7 +194,6 @@ export const mutations = {
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
chat.unread_count = unreadCount;
if (selectedChatId === conversationId) {
emitter.emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
}
@@ -225,7 +224,6 @@ export const mutations = {
const { messages, ...updates } = conversation;
allConversations[index] = { ...selectedConversation, ...updates };
if (_state.selectedChatId === conversation.id) {
emitter.emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
} else {
@@ -359,4 +359,83 @@ describe('#actions', () => {
).rejects.toThrow(Error);
});
});
describe('#initiateCall', () => {
const contactId = 123;
const inboxId = 456;
it('sends correct mutations if API is success', async () => {
const mockResponse = {
data: {
conversation_id: 789,
status: 'initiated',
},
};
axios.post.mockResolvedValue(mockResponse);
const result = await actions.initiateCall(
{ commit },
{ contactId, inboxId }
);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
]);
expect(result).toEqual(mockResponse.data);
});
it('sends correct actions if API returns error with message', async () => {
const errorMessage = 'Failed to initiate call';
axios.post.mockRejectedValue({
response: {
data: {
message: errorMessage,
},
},
});
await expect(
actions.initiateCall({ commit }, { contactId, inboxId })
).rejects.toThrow(ExceptionWithMessage);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
]);
});
it('sends correct actions if API returns error with error field', async () => {
const errorMessage = 'Call initiation error';
axios.post.mockRejectedValue({
response: {
data: {
error: errorMessage,
},
},
});
await expect(
actions.initiateCall({ commit }, { contactId, inboxId })
).rejects.toThrow(ExceptionWithMessage);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
]);
});
it('sends correct actions if API returns generic error', async () => {
axios.post.mockRejectedValue({ message: 'Network error' });
await expect(
actions.initiateCall({ commit }, { contactId, inboxId })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
]);
});
});
});
@@ -753,7 +753,6 @@ describe('#mutations', () => {
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(emitter.emit).toHaveBeenCalledWith('FETCH_LABEL_SUGGESTIONS');
expect(emitter.emit).toHaveBeenCalledWith('SCROLL_TO_MESSAGE');
});
@@ -0,0 +1,226 @@
/**
* Universal Store Factory
*
* This factory creates stores for both Vuex and Pinia, allowing gradual
* migration from Vuex to Pinia without breaking existing functionality.
*
* @module storeFactory
* @see https://pinia.vuejs.org/ - Pinia documentation
* @see https://vuex.vuejs.org/ - Vuex documentation
*/
import { defineStore } from 'pinia';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import {
// Vuex helpers
createRecord,
deleteRecord,
getRecords,
showRecord,
updateRecord,
// Pinia helpers
piniaGetRecords,
piniaShowRecord,
piniaCreateRecord,
piniaUpdateRecord,
piniaDeleteRecord,
} from './storeFactoryHelper';
export const generateMutationTypes = name => {
const capitalizedName = name.toUpperCase();
return {
SET_UI_FLAG: `SET_${capitalizedName}_UI_FLAG`,
SET: `SET_${capitalizedName}`,
ADD: `ADD_${capitalizedName}`,
EDIT: `EDIT_${capitalizedName}`,
DELETE: `DELETE_${capitalizedName}`,
SET_META: `SET_${capitalizedName}_META`,
UPSERT: `UPSERT_${capitalizedName}`,
};
};
export const createInitialState = () => ({
records: [],
meta: {},
uiFlags: {
fetchingList: false,
fetchingItem: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
});
export const createGetters = () => ({
getRecords: state => state.records.sort((r1, r2) => r2.id - r1.id),
getRecord: state => id =>
state.records.find(record => record.id === Number(id)) || {},
getUIFlags: state => state.uiFlags,
getMeta: state => state.meta,
});
export const createMutations = mutationTypes => ({
[mutationTypes.SET_UI_FLAG](state, data) {
state.uiFlags = {
...state.uiFlags,
...data,
};
},
[mutationTypes.SET_META](state, meta) {
state.meta = {
...state.meta,
totalCount: Number(meta.total_count),
page: Number(meta.page),
};
},
[mutationTypes.SET]: MutationHelpers.set,
[mutationTypes.ADD]: MutationHelpers.create,
[mutationTypes.EDIT]: MutationHelpers.update,
[mutationTypes.DELETE]: MutationHelpers.destroy,
[mutationTypes.UPSERT]: MutationHelpers.setSingleRecord,
});
export const createCrudActions = (API, mutationTypes) => ({
get: getRecords(mutationTypes, API),
show: showRecord(mutationTypes, API),
create: createRecord(mutationTypes, API),
update: updateRecord(mutationTypes, API),
delete: deleteRecord(mutationTypes, API),
});
/**
* Create Vuex store with standard CRUD operations
*
* @param {Object} options - Store configuration
* @param {string} options.name - Store name
* @param {Object} options.API - API client
* @param {Object} [options.getters] - Custom getters
* @param {Function} [options.actions] - Custom actions function
* @param {Object} [options.mutations] - Custom mutations
* @returns {Object} Vuex module configuration
*/
export const createVuexStore = options => {
const { name, API, actions, getters, mutations } = options;
const mutationTypes = generateMutationTypes(name);
const customActions = actions ? actions(mutationTypes) : {};
return {
namespaced: true,
state: createInitialState(),
getters: {
...createGetters(),
...(getters || {}),
},
mutations: {
...createMutations(mutationTypes),
...(mutations || {}),
},
actions: {
...createCrudActions(API, mutationTypes),
...customActions,
},
};
};
/**
* Create Pinia store with standard CRUD operations
*
* @param {Object} options - Store configuration
* @param {string} options.name - Store name
* @param {Object} options.API - API client
* @param {Object} [options.getters] - Custom getters
* @param {Function} [options.actions] - Custom actions function
* @returns {Function} Pinia store composable
*/
export const createPiniaStore = options => {
const { name, API, actions, getters } = options;
return defineStore(name.toLowerCase(), {
state: createInitialState,
getters: {
...createGetters(),
...(getters || {}),
},
actions: {
setUIFlag(data) {
this.uiFlags = {
...this.uiFlags,
...data,
};
},
setMeta(meta) {
this.meta = {
...this.meta,
totalCount: Number(meta.total_count || meta.totalCount || 0),
page: Number(meta.page || 1),
};
},
async get(params) {
return piniaGetRecords(this, API, params);
},
async show(id) {
return piniaShowRecord(this, API, id);
},
async create(obj) {
return piniaCreateRecord(this, API, obj);
},
async update(payload) {
return piniaUpdateRecord(this, API, payload);
},
async delete(id) {
return piniaDeleteRecord(this, API, id);
},
...(actions ? actions() : {}),
},
});
};
/**
* Universal Store Factory - Main Entry Point
*
* Creates either a Vuex or Pinia store based on the 'type' parameter.
* Defaults to Vuex for backward compatibility.
*
* @param {Object} options - Store configuration
* @param {string} options.name - Store name
* @param {Object} options.API - API client for CRUD operations
* @param {string} [options.type='vuex'] - Store type: 'vuex' or 'pinia'
* @param {Object} [options.getters] - Custom getters
* @param {Function} [options.actions] - Custom actions function
* @param {Object} [options.mutations] - Custom mutations (Vuex only)
*
* @returns {Object|Function} Vuex module or Pinia store composable
*
* @example
* Create Vuex store (default)
* export default createStore({
* name: 'Company',
* API: CompanyAPI,
* });
*
* @example
* Create Pinia store
* export const useCompaniesStore = createStore({
* name: 'Company',
* type: 'pinia',
* API: CompanyAPI,
* });
*/
export const createStore = options => {
const { type = 'vuex' } = options;
if (type === 'pinia') {
return createPiniaStore(options);
}
return createVuexStore(options);
};
@@ -0,0 +1,761 @@
import { setActivePinia, createPinia } from 'pinia';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import {
generateMutationTypes,
createInitialState,
createGetters,
createMutations,
createCrudActions,
createStore,
} from './storeFactory';
vi.mock('dashboard/store/utils/api', () => ({
throwErrorMessage: vi.fn(),
}));
vi.mock('shared/helpers/vuex/mutationHelpers', () => ({
set: vi.fn(),
create: vi.fn(),
update: vi.fn(),
destroy: vi.fn(),
setSingleRecord: vi.fn(),
}));
describe('storeFactory', () => {
describe('createStore with type parameter', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('creates Vuex store by default', () => {
const API = {};
const store = createStore({ name: 'test', API });
expect(store.namespaced).toBe(true);
expect(store.state).toBeDefined();
expect(store.mutations).toBeDefined();
});
it('creates Pinia store when type is "pinia"', () => {
const API = {
get: vi.fn(),
show: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
const useTestStore = createStore({
name: 'test',
API,
type: 'pinia',
});
const store = useTestStore();
expect(store.records).toBeDefined();
expect(store.get).toBeTypeOf('function');
expect(store.setUIFlag).toBeTypeOf('function');
});
it('creates Vuex store when type is "vuex"', () => {
const API = {};
const store = createStore({
name: 'test',
API,
type: 'vuex',
});
expect(store.namespaced).toBe(true);
expect(store.state).toBeDefined();
});
});
describe('generateMutationTypes', () => {
it('generates correct mutation types with capitalized name', () => {
const result = generateMutationTypes('test');
expect(result).toEqual({
SET_UI_FLAG: 'SET_TEST_UI_FLAG',
SET: 'SET_TEST',
ADD: 'ADD_TEST',
EDIT: 'EDIT_TEST',
DELETE: 'DELETE_TEST',
SET_META: 'SET_TEST_META',
UPSERT: 'UPSERT_TEST',
});
});
});
describe('createInitialState', () => {
it('returns the correct initial state structure', () => {
const result = createInitialState();
expect(result).toEqual({
records: [],
meta: {},
uiFlags: {
fetchingList: false,
fetchingItem: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
});
});
});
describe('createGetters', () => {
it('returns getters with correct implementations', () => {
const getters = createGetters();
const state = {
records: [{ id: 2 }, { id: 1 }, { id: 3 }],
uiFlags: { fetchingList: true },
meta: { totalCount: 10, page: 1 },
};
expect(getters.getRecords(state)).toEqual([
{ id: 3 },
{ id: 2 },
{ id: 1 },
]);
expect(getters.getRecord(state)(2)).toEqual({ id: 2 });
expect(getters.getRecord(state)(4)).toEqual({});
expect(getters.getUIFlags(state)).toEqual({
fetchingList: true,
});
expect(getters.getMeta(state)).toEqual({
totalCount: 10,
page: 1,
});
});
});
describe('createMutations', () => {
it('creates mutations with correct implementations', () => {
const mutationTypes = generateMutationTypes('test');
const mutations = createMutations(mutationTypes);
const state = { uiFlags: { fetchingList: false } };
mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true });
expect(state.uiFlags).toEqual({ fetchingList: true });
const metaState = { meta: {} };
mutations[mutationTypes.SET_META](metaState, {
total_count: '10',
page: '2',
});
expect(metaState.meta).toEqual({ totalCount: 10, page: 2 });
expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set);
expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create);
expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update);
expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy);
expect(mutations[mutationTypes.UPSERT]).toBe(
MutationHelpers.setSingleRecord
);
});
});
describe('createCrudActions', () => {
let API;
let commit;
let mutationTypes;
let actions;
beforeEach(() => {
API = {
get: vi.fn(),
show: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
commit = vi.fn();
mutationTypes = generateMutationTypes('test');
actions = createCrudActions(API, mutationTypes);
});
describe('get action', () => {
it('handles successful API response', async () => {
const payload = [{ id: 1 }];
const meta = { total_count: 10, page: 1 };
API.get.mockResolvedValue({ data: { payload, meta } });
const result = await actions.get({ commit });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: false,
});
expect(result).toEqual(payload);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.get.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.get({ commit });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('show action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Test' };
API.show.mockResolvedValue({ data });
const result = await actions.show({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.show.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.show({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('create action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Test' };
API.create.mockResolvedValue({ data });
const result = await actions.create({ commit }, { name: 'Test' });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.create.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.create({ commit }, { name: 'Test' });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('update action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Updated' };
API.update.mockResolvedValue({ data });
const result = await actions.update(
{ commit },
{ id: 1, name: 'Updated' }
);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: true,
});
expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.update.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.update(
{ commit },
{ id: 1, name: 'Updated' }
);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('delete action', () => {
it('handles successful API response', async () => {
API.delete.mockResolvedValue({});
const result = await actions.delete({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: true,
});
expect(API.delete).toHaveBeenCalledWith(1);
expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: false,
});
expect(result).toEqual(1);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.delete.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.delete({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
});
describe('createStore - Vuex type', () => {
it('creates a complete Vuex store with default options', () => {
const API = {};
const store = createStore({ name: 'test', API, type: 'vuex' });
expect(store.namespaced).toBe(true);
expect(store.state).toEqual(createInitialState());
expect(Object.keys(store.getters)).toEqual([
'getRecords',
'getRecord',
'getUIFlags',
'getMeta',
]);
expect(Object.keys(store.mutations)).toEqual([
'SET_TEST_UI_FLAG',
'SET_TEST_META',
'SET_TEST',
'ADD_TEST',
'EDIT_TEST',
'DELETE_TEST',
'UPSERT_TEST',
]);
expect(Object.keys(store.actions)).toEqual([
'get',
'show',
'create',
'update',
'delete',
]);
});
it('creates a Vuex store with custom actions and getters', () => {
const API = {};
const customGetters = { customGetter: () => 'custom' };
const customActions = () => ({
customAction: () => 'custom',
});
const store = createStore({
name: 'test',
API,
type: 'vuex',
getters: customGetters,
actions: customActions,
});
expect(store.getters).toHaveProperty('customGetter');
expect(store.actions).toHaveProperty('customAction');
expect(Object.keys(store.getters)).toEqual([
'getRecords',
'getRecord',
'getUIFlags',
'getMeta',
'customGetter',
]);
expect(Object.keys(store.actions)).toEqual([
'get',
'show',
'create',
'update',
'delete',
'customAction',
]);
});
it('creates a Vuex store with custom mutations', () => {
const API = {};
const customMutations = {
CUSTOM_MUTATION: state => {
state.custom = true;
},
};
const store = createStore({
name: 'test',
API,
type: 'vuex',
mutations: customMutations,
});
expect(store.mutations).toHaveProperty('CUSTOM_MUTATION');
});
});
describe('createStore - Pinia type', () => {
let API;
beforeEach(() => {
setActivePinia(createPinia());
API = {
get: vi.fn(),
show: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
});
it('creates a Pinia store with correct structure', () => {
const useTestStore = createStore({
name: 'test',
API,
type: 'pinia',
});
const store = useTestStore();
expect(store.records).toEqual([]);
expect(store.meta).toEqual({});
expect(store.uiFlags).toEqual({
fetchingList: false,
fetchingItem: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
});
});
it('has standard getters', () => {
const useTestStore = createStore({
name: 'test',
API,
type: 'pinia',
});
const store = useTestStore();
store.records = [{ id: 2 }, { id: 1 }, { id: 3 }];
store.meta = { totalCount: 10, page: 1 };
store.uiFlags = { fetchingList: true };
expect(store.getRecords).toEqual([{ id: 3 }, { id: 2 }, { id: 1 }]);
expect(store.getRecord(2)).toEqual({ id: 2 });
expect(store.getRecord(4)).toEqual({});
expect(store.getUIFlags).toEqual({ fetchingList: true });
expect(store.getMeta).toEqual({ totalCount: 10, page: 1 });
});
it('has custom getters', () => {
const useTestStore = createStore({
name: 'test',
API,
type: 'pinia',
getters: {
customGetter: state => state.records.length,
},
});
const store = useTestStore();
store.records = [{ id: 1 }, { id: 2 }];
expect(store.customGetter).toBe(2);
});
describe('setUIFlag action', () => {
it('updates UI flags correctly', () => {
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
store.setUIFlag({ fetchingList: true });
expect(store.uiFlags.fetchingList).toBe(true);
store.setUIFlag({ creatingItem: true });
expect(store.uiFlags.fetchingList).toBe(true);
expect(store.uiFlags.creatingItem).toBe(true);
});
});
describe('setMeta action', () => {
it('updates meta with snake_case input', () => {
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
store.setMeta({ total_count: '10', page: '2' });
expect(store.meta.totalCount).toBe(10);
expect(store.meta.page).toBe(2);
});
it('updates meta with camelCase input', () => {
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
store.setMeta({ totalCount: 15, page: 3 });
expect(store.meta.totalCount).toBe(15);
expect(store.meta.page).toBe(3);
});
});
describe('get action', () => {
it('fetches records successfully', async () => {
const payload = [{ id: 1, name: 'Test' }];
const meta = { total_count: 1, page: 1 };
API.get.mockResolvedValue({ data: { payload, meta } });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
await store.get({ page: 1 });
expect(API.get).toHaveBeenCalledWith({ page: 1 });
expect(store.records).toEqual(payload);
expect(store.meta.totalCount).toBe(1);
expect(store.meta.page).toBe(1);
expect(store.uiFlags.fetchingList).toBe(false);
});
it('handles API response without meta', async () => {
const data = [{ id: 1, name: 'Test' }];
API.get.mockResolvedValue({ data });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
await store.get();
expect(store.records).toEqual(data);
});
it('handles errors and resets UI flags', async () => {
const error = new Error('API Error');
API.get.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
const result = await store.get();
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(store.uiFlags.fetchingList).toBe(false);
expect(result).toBe('Error thrown');
});
});
describe('show action', () => {
it('fetches and upserts a record', async () => {
const data = { id: 1, name: 'Test' };
API.show.mockResolvedValue({ data });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
const result = await store.show(1);
expect(API.show).toHaveBeenCalledWith(1);
expect(store.records).toContainEqual(data);
expect(result).toEqual(data);
expect(store.uiFlags.fetchingItem).toBe(false);
});
it('updates existing record', async () => {
const data = { id: 1, name: 'Updated' };
API.show.mockResolvedValue({ data });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
store.records = [{ id: 1, name: 'Original' }];
await store.show(1);
expect(store.records).toHaveLength(1);
expect(store.records[0].name).toBe('Updated');
});
it('handles payload wrapper', async () => {
const record = { id: 1, name: 'Test' };
API.show.mockResolvedValue({ data: { payload: record } });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
const result = await store.show(1);
expect(result).toEqual(record);
expect(store.records).toContainEqual(record);
});
});
describe('create action', () => {
it('creates a new record', async () => {
const data = { id: 1, name: 'New' };
API.create.mockResolvedValue({ data });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
const result = await store.create({ name: 'New' });
expect(API.create).toHaveBeenCalledWith({ name: 'New' });
expect(store.records).toContainEqual(data);
expect(result).toEqual(data);
expect(store.uiFlags.creatingItem).toBe(false);
});
it('handles payload wrapper', async () => {
const record = { id: 1, name: 'New' };
API.create.mockResolvedValue({ data: { payload: record } });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
const result = await store.create({ name: 'New' });
expect(result).toEqual(record);
expect(store.records).toContainEqual(record);
});
});
describe('update action', () => {
it('updates an existing record', async () => {
const data = { id: 1, name: 'Updated' };
API.update.mockResolvedValue({ data });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
store.records = [{ id: 1, name: 'Original' }];
const result = await store.update({ id: 1, name: 'Updated' });
expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
expect(store.records[0].name).toBe('Updated');
expect(result).toEqual(data);
expect(store.uiFlags.updatingItem).toBe(false);
});
it('handles payload wrapper', async () => {
const record = { id: 1, name: 'Updated' };
API.update.mockResolvedValue({ data: { payload: record } });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
store.records = [{ id: 1, name: 'Original' }];
const result = await store.update({ id: 1, name: 'Updated' });
expect(result).toEqual(record);
expect(store.records[0]).toEqual(record);
});
});
describe('delete action', () => {
it('deletes a record', async () => {
API.delete.mockResolvedValue({});
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
store.records = [{ id: 1 }, { id: 2 }, { id: 3 }];
const result = await store.delete(2);
expect(API.delete).toHaveBeenCalledWith(2);
expect(store.records).toHaveLength(2);
expect(store.records).not.toContainEqual({ id: 2 });
expect(result).toBe(2);
expect(store.uiFlags.deletingItem).toBe(false);
});
});
describe('custom actions', () => {
it('includes custom actions', async () => {
const useTestStore = createStore({
name: 'test',
API,
type: 'pinia',
actions: () => ({
customAction() {
return 'custom result';
},
}),
});
const store = useTestStore();
const result = store.customAction();
expect(result).toBe('custom result');
});
it('custom actions can access store state', () => {
const useTestStore = createStore({
name: 'test',
API,
type: 'pinia',
actions: () => ({
getRecordCount() {
return this.records.length;
},
}),
});
const store = useTestStore();
store.records = [{ id: 1 }, { id: 2 }];
expect(store.getRecordCount()).toBe(2);
});
});
});
});
@@ -0,0 +1,203 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
// ============================================================================
// VUEX HELPERS
// ============================================================================
export const getRecords =
(mutationTypes, API) =>
async ({ commit }, params = {}) => {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
try {
const response = await API.get(params);
commit(mutationTypes.SET, response.data.payload);
commit(mutationTypes.SET_META, response.data.meta);
return response.data.payload;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
}
};
export const showRecord =
(mutationTypes, API) =>
async ({ commit }, id) => {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
try {
const response = await API.show(id);
commit(mutationTypes.UPSERT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
}
};
export const createRecord =
(mutationTypes, API) =>
async ({ commit }, dataObj) => {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
try {
const response = await API.create(dataObj);
commit(mutationTypes.UPSERT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
}
};
export const updateRecord =
(mutationTypes, API) =>
async ({ commit }, { id, ...updateObj }) => {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
try {
const response = await API.update(id, updateObj);
commit(mutationTypes.EDIT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
}
};
export const deleteRecord =
(mutationTypes, API) =>
async ({ commit }, id) => {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
try {
await API.delete(id);
commit(mutationTypes.DELETE, id);
return id;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
}
};
// ============================================================================
// PINIA HELPERS
// ============================================================================
/**
* Get records from API and update Pinia store
* @param {Object} store - Pinia store instance (this context)
* @param {Object} API - API client
* @param {Object} params - Query parameters
*/
export const piniaGetRecords = async (store, API, params = {}) => {
store.setUIFlag({ fetchingList: true });
try {
const response = await API.get(params);
const { data } = response;
store.records = data.payload || data;
if (data.meta) {
store.setMeta(data.meta);
}
return data.payload || data;
} catch (error) {
return throwErrorMessage(error);
} finally {
store.setUIFlag({ fetchingList: false });
}
};
/**
* Show single record from API and upsert to Pinia store
* @param {Object} store - Pinia store instance (this context)
* @param {Object} API - API client
* @param {Number|String} id - Record ID
*/
export const piniaShowRecord = async (store, API, id) => {
store.setUIFlag({ fetchingItem: true });
try {
const response = await API.show(id);
const { data } = response;
const record = data.payload || data;
// Upsert logic
const index = store.records.findIndex(r => r.id === record.id);
if (index !== -1) {
store.records[index] = record;
} else {
store.records.push(record);
}
return record;
} catch (error) {
return throwErrorMessage(error);
} finally {
store.setUIFlag({ fetchingItem: false });
}
};
/**
* Create new record via API and add to Pinia store
* @param {Object} store - Pinia store instance (this context)
* @param {Object} API - API client
* @param {Object} dataObj - Data to create
*/
export const piniaCreateRecord = async (store, API, dataObj) => {
store.setUIFlag({ creatingItem: true });
try {
const response = await API.create(dataObj);
const { data } = response;
const record = data.payload || data;
store.records.push(record);
return record;
} catch (error) {
return throwErrorMessage(error);
} finally {
store.setUIFlag({ creatingItem: false });
}
};
/**
* Update existing record via API and update in Pinia store
* @param {Object} store - Pinia store instance (this context)
* @param {Object} API - API client
* @param {Object} payload - Update payload with id
*/
export const piniaUpdateRecord = async (store, API, { id, ...updateObj }) => {
store.setUIFlag({ updatingItem: true });
try {
const response = await API.update(id, updateObj);
const { data } = response;
const record = data.payload || data;
const index = store.records.findIndex(r => r.id === record.id);
if (index !== -1) {
store.records[index] = record;
}
return record;
} catch (error) {
return throwErrorMessage(error);
} finally {
store.setUIFlag({ updatingItem: false });
}
};
/**
* Delete record via API and remove from Pinia store
* @param {Object} store - Pinia store instance (this context)
* @param {Object} API - API client
* @param {Number|String} id - Record ID to delete
*/
export const piniaDeleteRecord = async (store, API, id) => {
store.setUIFlag({ deletingItem: true });
try {
await API.delete(id);
store.records = store.records.filter(record => record.id !== id);
return id;
} catch (error) {
return throwErrorMessage(error);
} finally {
store.setUIFlag({ deletingItem: false });
}
};
@@ -0,0 +1,31 @@
import CompanyAPI from 'dashboard/api/companies';
import { createStore } from 'dashboard/store/storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import camelcaseKeys from 'camelcase-keys';
export const useCompaniesStore = createStore({
name: 'companies',
type: 'pinia',
API: CompanyAPI,
getters: {
getCompaniesList: state => {
return camelcaseKeys(state.records, { deep: true });
},
},
actions: () => ({
async search({ search, page, sort }) {
this.setUIFlag({ fetchingList: true });
try {
const {
data: { payload, meta },
} = await CompanyAPI.search(search, page, sort);
this.records = payload;
this.setMeta(meta);
} catch (error) {
throwErrorMessage(error);
} finally {
this.setUIFlag({ fetchingList: false });
}
},
}),
});
+4
View File
@@ -16,6 +16,7 @@ import createAxios from 'dashboard/helper/APIHelper';
import commonHelpers, { isJSONValid } from 'dashboard/helper/commons';
import { sync } from 'vuex-router-sync';
import { createPinia } from 'pinia';
import router, { initalizeRouter } from 'dashboard/routes';
import store from 'dashboard/store';
import constants from 'dashboard/constants/globals';
@@ -41,9 +42,12 @@ const i18n = createI18n({
sync(store, router);
const pinia = createPinia();
const app = createApp(App);
app.use(i18n);
app.use(store);
app.use(pinia);
app.use(router);
// [VITE] Disabled this, need to renable later
@@ -0,0 +1,188 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useLocale } from '../useLocale';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
vi.mock('vue-i18n');
describe('useLocale', () => {
beforeEach(() => {
vi.mocked(useI18n).mockReturnValue({
locale: ref('en-US'),
});
});
describe('resolvedLocale', () => {
it('should return normalized locale for valid hyphen-based tags', () => {
const mockLocale = ref('en-US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
expect(resolvedLocale.value).toBe('en-US');
});
it('should normalize underscore-based locale tags to hyphens', () => {
const mockLocale = ref('pt_BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should normalize pt_BR to pt-BR
expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
});
it('should normalize zh_CN to zh-CN or fall back to zh', () => {
const mockLocale = ref('zh_CN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should normalize zh_CN to zh-CN or fall back to zh
expect(resolvedLocale.value).toMatch(/^zh(-CN)?$/);
});
it('should normalize en_US to en-US or fall back to en', () => {
const mockLocale = ref('en_US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should normalize en_US to en-US or fall back to en
expect(resolvedLocale.value).toMatch(/^en(-US)?$/);
});
it('should fall back to base language when specific locale not supported', () => {
// Use a specific locale that might not be fully supported
const mockLocale = ref('pt-BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should return either pt-BR or pt (base language)
expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
});
it('should fall back to English for completely unsupported locales', () => {
const mockLocale = ref('xx-YY');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should fall back to 'en'
expect(resolvedLocale.value).toBe('en');
});
it('should handle null locale gracefully', () => {
const mockLocale = ref(null);
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should fall back to 'en'
expect(resolvedLocale.value).toBe('en');
});
it('should handle undefined locale gracefully', () => {
const mockLocale = ref(undefined);
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should fall back to 'en'
expect(resolvedLocale.value).toBe('en');
});
it('should handle base language code without region', () => {
const mockLocale = ref('pt');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should work with base language
expect(resolvedLocale.value).toBe('pt');
});
it('should handle multiple underscores in locale tag', () => {
const mockLocale = ref('zh_Hans_CN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should normalize all underscores to hyphens
expect(resolvedLocale.value).toMatch(/^zh(-Hans-CN|-Hans|-CN)?$/);
});
it('should be reactive to locale changes', () => {
const mockLocale = ref('en-US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
expect(resolvedLocale.value).toBe('en-US');
// Change locale
mockLocale.value = 'pt_BR';
// Should update reactively
expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
});
it('should work with common locales', () => {
const testCases = [
{ input: 'de-DE', expected: /^de(-DE)?$/ },
{ input: 'fr-FR', expected: /^fr(-FR)?$/ },
{ input: 'es-ES', expected: /^es(-ES)?$/ },
{ input: 'ja-JP', expected: /^ja(-JP)?$/ },
{ input: 'ko-KR', expected: /^ko(-KR)?$/ },
{ input: 'ar-SA', expected: /^ar(-SA)?$/ },
{ input: 'hi-IN', expected: /^hi(-IN)?$/ },
{ input: 'ru-RU', expected: /^ru(-RU)?$/ },
{ input: 'tr-TR', expected: /^tr(-TR)?$/ },
];
testCases.forEach(({ input, expected }) => {
const mockLocale = ref(input);
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
expect(resolvedLocale.value).toMatch(expected);
});
});
});
describe('locale (raw)', () => {
it('should expose raw locale value', () => {
const mockLocale = ref('pt_BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { locale } = useLocale();
// Raw locale should be unchanged
expect(locale.value).toBe('pt_BR');
});
it('should be reactive', () => {
const mockLocale = ref('en-US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { locale } = useLocale();
expect(locale.value).toBe('en-US');
mockLocale.value = 'pt-BR';
expect(locale.value).toBe('pt-BR');
});
});
describe('Intl API compatibility', () => {
it('should work with Intl.NumberFormat', () => {
const mockLocale = ref('pt_BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should not throw error
expect(() => {
new Intl.NumberFormat(resolvedLocale.value).format(1234567);
}).not.toThrow();
});
it('should work with Intl.DateTimeFormat', () => {
const mockLocale = ref('zh_CN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should not throw error
expect(() => {
new Intl.DateTimeFormat(resolvedLocale.value).format(new Date());
}).not.toThrow();
});
it('should work with Intl.Collator', () => {
const mockLocale = ref('en_US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should not throw error
expect(() => {
new Intl.Collator(resolvedLocale.value).compare('a', 'b');
}).not.toThrow();
});
});
});
@@ -0,0 +1,364 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useNumberFormatter } from '../useNumberFormatter';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
vi.mock('vue-i18n');
describe('useNumberFormatter', () => {
beforeEach(() => {
vi.mocked(useI18n).mockReturnValue({
locale: ref('en-US'),
});
});
describe('formatCompactNumber', () => {
it('should return exact numbers for values under 1,000', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(0)).toBe('0');
expect(formatCompactNumber(1)).toBe('1');
expect(formatCompactNumber(42)).toBe('42');
expect(formatCompactNumber(999)).toBe('999');
});
it('should return "Xk" for exact thousands and "Xk+" for values with remainder', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(1000)).toBe('1k');
expect(formatCompactNumber(1020)).toBe('1k+');
expect(formatCompactNumber(1500)).toBe('1k+');
expect(formatCompactNumber(1999)).toBe('1k+');
expect(formatCompactNumber(2000)).toBe('2k');
expect(formatCompactNumber(15000)).toBe('15k');
expect(formatCompactNumber(15500)).toBe('15k+');
expect(formatCompactNumber(999999)).toBe('999k+');
});
it('should return millions/billion/trillion format for values 1,000,000 and above', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(1000000)).toBe('1M');
expect(formatCompactNumber(1000001)).toBe('1.0M');
expect(formatCompactNumber(1200000)).toBe('1.2M');
expect(formatCompactNumber(1234000)).toBe('1.2M');
expect(formatCompactNumber(2500000)).toBe('2.5M');
expect(formatCompactNumber(10000000)).toBe('10M');
expect(formatCompactNumber(1000000000)).toBe('1B');
expect(formatCompactNumber(1100000000)).toBe('1.1B');
expect(formatCompactNumber(10000000000)).toBe('10B');
expect(formatCompactNumber(11000000000)).toBe('11B');
expect(formatCompactNumber(1000000000000)).toBe('1T');
expect(formatCompactNumber(1100000000000)).toBe('1.1T');
expect(formatCompactNumber(10000000000000)).toBe('10T');
expect(formatCompactNumber(11000000000000)).toBe('11T');
});
it('should handle edge cases gracefully', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(null)).toBe('0');
expect(formatCompactNumber(undefined)).toBe('0');
expect(formatCompactNumber(NaN)).toBe('0');
expect(formatCompactNumber('string')).toBe('0');
});
it('should handle negative numbers', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(-500)).toBe('-500');
expect(formatCompactNumber(-1000)).toBe('-1k');
expect(formatCompactNumber(-1500)).toBe('-1k+');
expect(formatCompactNumber(-2000)).toBe('-2k');
expect(formatCompactNumber(-1200000)).toBe('-1.2M');
});
it('should format with en-US locale', () => {
const mockLocale = ref('en-US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(1500)).toBe('1k+');
});
it('should format with de-DE locale', () => {
const mockLocale = ref('de-DE');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(2000)).toBe('2k');
});
it('should format with fr-FR locale (compact notation)', () => {
const mockLocale = ref('fr-FR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
const result = formatCompactNumber(1000000);
expect(result).toMatch(/1\s*M/); // French uses space before M
});
it('should format with ja-JP locale', () => {
const mockLocale = ref('ja-JP');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(999)).toBe('999');
});
it('should format with ar-SA locale (Arabic numerals)', () => {
const mockLocale = ref('ar-SA');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
const result = formatCompactNumber(5000);
expect(result).toContain('k');
expect(typeof result).toBe('string');
});
it('should format with es-ES locale', () => {
const mockLocale = ref('es-ES');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(7500)).toBe('7k+');
});
it('should format with hi-IN locale', () => {
const mockLocale = ref('hi-IN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(100000)).toBe('100k');
});
it('should format with ru-RU locale', () => {
const mockLocale = ref('ru-RU');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(3000)).toBe('3k');
});
it('should format with ko-KR locale (uses 만 for 10,000)', () => {
const mockLocale = ref('ko-KR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
const result = formatCompactNumber(2500000);
expect(result).toContain('만'); // Korean uses 만 (10,000) as a unit, so 2,500,000 should contain 만
});
it('should format with pt-BR locale', () => {
const mockLocale = ref('pt-BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(8888)).toBe('8k+');
});
it('should handle underscore-based locale tags (pt_BR)', () => {
const mockLocale = ref('pt_BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
// Should normalize pt_BR to pt-BR and work correctly
expect(formatCompactNumber(8888)).toBe('8k+');
expect(formatCompactNumber(1000000)).toBe('1\u00a0mi');
});
it('should handle underscore-based locale tags (zh_CN)', () => {
const mockLocale = ref('zh_CN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
// Should normalize zh_CN to zh-CN and work correctly
expect(formatCompactNumber(999)).toBe('999');
expect(formatCompactNumber(5000)).toBe('5k');
});
it('should handle underscore-based locale tags (en_US)', () => {
const mockLocale = ref('en_US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
// Should normalize en_US to en-US and work correctly
expect(formatCompactNumber(1500)).toBe('1k+');
expect(formatCompactNumber(1000000)).toBe('1M');
});
it('should handle null/undefined locale gracefully', () => {
const mockLocale = ref(null);
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
// Should fall back to 'en' locale
expect(formatCompactNumber(1500)).toBe('1k+');
});
it('should fall back to base language when specific locale not supported', () => {
// Simulate a case where pt-BR might not be fully supported but pt is
const mockLocale = ref('pt-BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
// Should work with either pt-BR or pt fallback
const result = formatCompactNumber(1500);
expect(result).toMatch(/1k\+/);
});
it('should fall back to English for completely unsupported locales', () => {
// Use a completely made-up locale
const mockLocale = ref('xx-YY');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
// Should fall back to 'en' and work
expect(formatCompactNumber(1500)).toBe('1k+');
expect(formatCompactNumber(1000000)).toBe('1M');
});
it('should handle edge case with only base language code', () => {
const mockLocale = ref('pt');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
// Should work with base language
expect(formatCompactNumber(2000)).toBe('2k');
});
});
describe('formatFullNumber', () => {
it('should format numbers with locale-specific formatting', () => {
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(1000)).toBe('1,000');
expect(formatFullNumber(1234567)).toBe('1,234,567');
expect(formatFullNumber(1234567890)).toBe('1,234,567,890');
expect(formatFullNumber(1234567890123)).toBe('1,234,567,890,123');
});
it('should handle edge cases gracefully', () => {
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(null)).toBe('0');
expect(formatFullNumber(undefined)).toBe('0');
expect(formatFullNumber(NaN)).toBe('0');
expect(formatFullNumber('string')).toBe('0');
});
it('should format with en-US locale (comma separator)', () => {
const mockLocale = ref('en-US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(1234567)).toBe('1,234,567');
});
it('should format with de-DE locale (period separator)', () => {
const mockLocale = ref('de-DE');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(9876543)).toBe('9.876.543');
});
it('should format with es-ES locale (period separator)', () => {
const mockLocale = ref('es-ES');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(5555555)).toBe('5.555.555');
});
it('should format with zh-CN locale', () => {
const mockLocale = ref('zh-CN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(1000000)).toBe('1,000,000');
});
it('should format with ar-EG locale (Arabic numerals, RTL)', () => {
const mockLocale = ref('ar-EG');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
const result = formatFullNumber(7654321);
// Arabic locale uses Eastern Arabic numerals (٠-٩)
// Just verify it's formatted (length should be reasonable)
expect(result.length).toBeGreaterThan(6);
});
it('should format with fr-FR locale (narrow no-break space)', () => {
const mockLocale = ref('fr-FR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
const result = formatFullNumber(3333333);
expect(result).toContain('3');
expect(result).toContain('333');
});
it('should format with hi-IN locale (Indian numbering system)', () => {
const mockLocale = ref('hi-IN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(9999999)).toBe('99,99,999');
});
it('should format with th-TH locale', () => {
const mockLocale = ref('th-TH');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(4444444)).toBe('4,444,444');
});
it('should format with tr-TR locale', () => {
const mockLocale = ref('tr-TR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(6666666)).toBe('6.666.666');
});
it('should format with pt-PT locale (space separator)', () => {
const mockLocale = ref('pt-PT');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
const result = formatFullNumber(2222222);
// Portuguese (Portugal) uses narrow no-break space as separator
expect(result).toMatch(/2[\s\u202f]222[\s\u202f]222/);
});
it('should handle underscore-based locale tags (pt_BR)', () => {
const mockLocale = ref('pt_BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
// Should normalize pt_BR to pt-BR and work correctly
expect(formatFullNumber(1234567)).toBe('1.234.567');
});
it('should handle underscore-based locale tags (zh_CN)', () => {
const mockLocale = ref('zh_CN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
// Should normalize zh_CN to zh-CN and work correctly
expect(formatFullNumber(1000000)).toBe('1,000,000');
});
it('should handle underscore-based locale tags (en_US)', () => {
const mockLocale = ref('en_US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
// Should normalize en_US to en-US and work correctly
expect(formatFullNumber(1234567)).toBe('1,234,567');
});
it('should handle null/undefined locale gracefully', () => {
const mockLocale = ref(null);
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
// Should fall back to 'en' locale
expect(formatFullNumber(1234567)).toBe('1,234,567');
});
it('should fall back to base language when specific locale not supported', () => {
// Simulate a case where pt-BR might not be fully supported but pt is
const mockLocale = ref('pt-BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
// Should work with either pt-BR or pt fallback
const result = formatFullNumber(1234567);
// Portuguese uses period as thousands separator
expect(result).toMatch(/1[.,\s]234[.,\s]567/);
});
it('should fall back to English for completely unsupported locales', () => {
// Use a completely made-up locale
const mockLocale = ref('xx-YY');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
// Should fall back to 'en' and work
expect(formatFullNumber(1234567)).toBe('1,234,567');
});
it('should handle edge case with only base language code', () => {
const mockLocale = ref('pt');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
// Should work with base language
const result = formatFullNumber(1000000);
expect(result).toMatch(/1[.,\s]000[.,\s]000/);
});
});
});
@@ -0,0 +1,57 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
/**
* Composable for locale resolution and validation
* Provides a normalized, validated locale that works with Intl APIs
*/
export function useLocale() {
const { locale } = useI18n();
/**
* Resolves and validates the current locale for use with Intl APIs
*
* Handles multiple fallback scenarios:
* 1. Normalizes underscore-based tags (pt_BR pt-BR, zh_CN zh-CN)
* 2. Falls back to base language if specific locale unsupported (pt-BR pt)
* 3. Falls back to English if base language unsupported (xx-YY en)
*
* @returns {string} Valid BCP 47 locale tag for Intl APIs
*
* @example
* const { resolvedLocale } = useLocale();
* new Intl.NumberFormat(resolvedLocale.value).format(1234);
* new Intl.DateTimeFormat(resolvedLocale.value).format(new Date());
*/
const resolvedLocale = computed(() => {
// Handle null/undefined locale
if (!locale.value) return 'en';
// Normalize underscore to hyphen (pt_BR → pt-BR, zh_CN → zh-CN)
const normalized = locale.value.replace(/_/g, '-');
// Check if the specific locale is supported (e.g., pt-BR, zh-CN)
const supportedLocales = Intl.NumberFormat.supportedLocalesOf([normalized]);
if (supportedLocales.length > 0) {
return normalized;
}
// If specific locale not supported, try base language (pt-BR → pt, zh-CN → zh)
const baseLocale = normalized.split('-')[0];
const baseSupportedLocales = Intl.NumberFormat.supportedLocalesOf([
baseLocale,
]);
if (baseSupportedLocales.length > 0) {
return baseLocale;
}
// If base language also not supported, fall back to English
return 'en';
});
return {
resolvedLocale,
// Also expose the raw locale for cases where you need it
locale,
};
}
@@ -0,0 +1,67 @@
import { useLocale } from './useLocale';
/**
* Composable for number formatting with i18n locale support
* Provides methods to format numbers in compact and full display formats
*/
export function useNumberFormatter() {
const { resolvedLocale } = useLocale();
/**
* Formats numbers for display with clean, minimal formatting
* - Up to 1,000: show exact number (e.g., 999)
* - 1,000 to 999,999: show as "Xk" for exact thousands or "Xk+" for remainder (e.g., 1000 "1k", 1500 "1k+")
* - 1,000,000+: show in millions with 1 decimal place (e.g., 1,234,000 "1.2M")
*
* Uses browser-native Intl.NumberFormat with proper i18n locale support
*
* @param {number} num - The number to format
* @returns {string} Formatted number string
*/
const formatCompactNumber = num => {
if (typeof num !== 'number' || Number.isNaN(num)) {
return '0';
}
// For numbers between -1000 and 1000 (exclusive), show exact number with locale formatting
if (Math.abs(num) < 1000) {
return new Intl.NumberFormat(resolvedLocale.value).format(num);
}
// For numbers with absolute value above 1,000,000, show in millions with 1 decimal place
if (Math.abs(num) >= 1000000) {
const millions = num / 1000000;
return new Intl.NumberFormat(resolvedLocale.value, {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1,
minimumFractionDigits: millions % 1 === 0 ? 0 : 1,
}).format(num);
}
// For numbers with absolute value between 1,000 and 1,000,000, show as "Xk" or "Xk+" using floor value
// For negative numbers, we want to floor towards zero (truncate), not towards negative infinity
const thousands = num >= 0 ? Math.floor(num / 1000) : Math.ceil(num / 1000);
const remainder = Math.abs(num) % 1000;
const suffix = remainder === 0 ? 'k' : 'k+';
return `${new Intl.NumberFormat(resolvedLocale.value).format(thousands)}${suffix}`;
};
/**
* Format a number for full display with locale-specific formatting
* @param {number} num - The number to format
* @returns {string} Formatted number string with full precision and locale formatting (e.g., 1,234,567)
*/
const formatFullNumber = num => {
if (typeof num !== 'number' || Number.isNaN(num)) {
return '0';
}
return new Intl.NumberFormat(resolvedLocale.value).format(num);
};
return {
formatCompactNumber,
formatFullNumber,
};
}
@@ -4,7 +4,6 @@ export const BUS_EVENTS = {
FOCUS_CUSTOM_ATTRIBUTE: 'FOCUS_CUSTOM_ATTRIBUTE',
SCROLL_TO_MESSAGE: 'SCROLL_TO_MESSAGE',
MESSAGE_SENT: 'MESSAGE_SENT',
FETCH_LABEL_SUGGESTIONS: 'FETCH_LABEL_SUGGESTIONS',
ON_MESSAGE_LIST_SCROLL: 'ON_MESSAGE_LIST_SCROLL',
WEBSOCKET_DISCONNECT: 'WEBSOCKET_DISCONNECT',
WEBSOCKET_RECONNECT: 'WEBSOCKET_RECONNECT',
+1
View File
@@ -48,6 +48,7 @@ class Account < ApplicationRecord
check_for_column: false
}.freeze
validates :name, presence: true
validates :domain, length: { maximum: 100 }
validates_with JsonSchemaValidator,
schema: SETTINGS_PARAMS_SCHEMA,
+2
View File
@@ -40,6 +40,8 @@ class Channel::Line < ApplicationRecord
config.channel_id = line_channel_id
config.channel_secret = line_channel_secret
config.channel_token = line_channel_token
# Skip SSL verification in development to avoid certificate issues
config.http_options = { verify_mode: OpenSSL::SSL::VERIFY_NONE } if Rails.env.development?
end
end
end
+1 -1
View File
@@ -125,7 +125,7 @@ class MailPresenter < SimpleDelegator
def from
# changing to downcase to avoid case mismatch while finding contact
(@mail.reply_to.presence || @mail.from).map(&:downcase)
Array.wrap(@mail.reply_to.presence || @mail.from).map(&:downcase)
end
def sender_name
@@ -71,3 +71,5 @@ class Contacts::ContactableInboxesService
end
end
end
Contacts::ContactableInboxesService.prepend_mod_with('Contacts::ContactableInboxesService')
@@ -3,8 +3,9 @@ class Whatsapp::OneoffCampaignService
def perform
validate_campaign!
process_audience(extract_audience_labels)
# marks campaign completed so that other jobs won't pick it up
campaign.completed!
process_audience(extract_audience_labels)
end
private
+25 -1
View File
@@ -199,7 +199,6 @@
description: 'The limits for the Captain AI service for different plans'
value:
type: code
# End of Captain Config
# ------- Chatwoot Internal Config for Cloud ----#
@@ -476,3 +475,28 @@
locked: false
description: 'Token expiry in days'
## ------ End of Customizations for Customers ------ ##
## ----- LLM Observability ---- ##
- name: OTEL_PROVIDER
display_title: 'OpenTelemetry Provider'
description: 'LLM observability provider (langfuse, langsmith, etc.)'
value: ''
locked: false
- name: LANGFUSE_PUBLIC_KEY
display_title: 'Langfuse Public Key'
description: 'Public key for Langfuse authentication'
value: ''
locked: false
type: secret
- name: LANGFUSE_SECRET_KEY
display_title: 'Langfuse Secret Key'
description: 'Secret key for Langfuse authentication'
value: ''
locked: false
type: secret
- name: LANGFUSE_BASE_URL
display_title: 'Langfuse Base URL'
description: 'Langfuse endpoint (US: https://us.cloud.langfuse.com, EU: https://cloud.langfuse.com)'
value: 'https://us.cloud.langfuse.com'
locked: false
## ---- End of LLM Observability ---- ##
+16 -1
View File
@@ -52,10 +52,25 @@ mp4:
Your browser does not support the video tag.
</video>
arcade_tab:
regex: 'https?://(?:www\.)?app\.arcade\.software/share/(?<video_id>[^&/?]+)\?(?:[^&#]*&)*embed_mobile=tab(?:[&#?][^ ]*)?'
template: |
<div style="position: relative; padding-bottom: calc(62.793% + 41px); height: 0px; width: 100%;">
<iframe
src="https://app.arcade.software/embed/%{video_id}?embed&embed_mobile=tab"
frameborder="0"
webkitallowfullscreen
mozallowfullscreen
allowfullscreen
allow="fullscreen"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</iframe>
</div>
arcade:
regex: 'https?://(?:www\.)?app\.arcade\.software/share/(?<video_id>[^&/]+)'
template: |
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<div style="position: relative; padding-bottom: calc(62.793% + 41px); height: 0px; width: 100%;">
<iframe
src="https://app.arcade.software/embed/%{video_id}"
frameborder="0"
+2
View File
@@ -177,6 +177,7 @@ Rails.application.routes.draw do
resources :contact_inboxes, only: [:create]
resources :labels, only: [:create, :index]
resources :notes
post :call, on: :member, to: 'calls#create' if ChatwootApp.enterprise?
end
end
resources :csat_survey_responses, only: [:index] do
@@ -550,6 +551,7 @@ Rails.application.routes.draw do
collection do
post 'call/:phone', action: :call_twiml
post 'status/:phone', action: :status
post 'conference_status/:phone', action: :conference_status
end
end
end
@@ -44,6 +44,7 @@ class ArticleKeyConverter
end
end
# rubocop:disable Style/OneClassPerFile
class UpdateArticleImageKeys < ActiveRecord::Migration[7.0]
def change
# Iterate through all articles
@@ -53,3 +54,4 @@ class UpdateArticleImageKeys < ActiveRecord::Migration[7.0]
end
end
end
# rubocop:enable Style/OneClassPerFile
@@ -0,0 +1,21 @@
module Enterprise::ContactInboxBuilder
private
def generate_source_id
return super unless @inbox.channel_type == 'Channel::Voice'
phone_source_id
end
def phone_source_id
return super unless @inbox.channel_type == 'Channel::Voice'
return SecureRandom.uuid if @contact.phone_number.blank?
@contact.phone_number
end
def allowed_channels?
super || @inbox.channel_type == 'Channel::Voice'
end
end
@@ -0,0 +1,9 @@
module Enterprise::Messages::MessageBuilder
private
def message_type
return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice'
super
end
end
@@ -0,0 +1,38 @@
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
before_action :contact
before_action :voice_inbox
def create
authorize contact, :show?
authorize voice_inbox, :show?
result = Voice::OutboundCallBuilder.perform!(
account: Current.account,
inbox: voice_inbox,
user: Current.user,
contact: contact
)
conversation = result[:conversation]
render json: {
conversation_id: conversation.display_id,
inbox_id: voice_inbox.id,
call_sid: result[:call_sid],
conference_sid: conversation.additional_attributes['conference_sid']
}
end
private
def contact
@contact ||= Current.account.contacts.find(params[:id])
end
def voice_inbox
@voice_inbox ||= Current.user.assigned_inboxes.where(
account_id: Current.account.id,
channel_type: 'Channel::Voice'
).find(params.require(:inbox_id))
end
end
@@ -36,7 +36,8 @@ module Enterprise::SuperAdmin::AppConfigsController
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS
SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID]
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID
OTEL_PROVIDER LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL]
end
def captain_config_options
@@ -1,38 +1,189 @@
class Twilio::VoiceController < ApplicationController
CONFERENCE_EVENT_PATTERNS = {
/conference-start/i => 'start',
/participant-join/i => 'join',
/participant-leave/i => 'leave',
/conference-end/i => 'end'
}.freeze
before_action :set_inbox!
def status
Voice::StatusUpdateService.new(
account: @inbox.account,
call_sid: params[:CallSid],
call_status: params[:CallStatus]
account: current_account,
call_sid: twilio_call_sid,
call_status: params[:CallStatus],
payload: params.to_unsafe_h
).perform
head :no_content
end
def call_twiml
account = @inbox.account
call_sid = params[:CallSid]
from_number = params[:From].to_s
to_number = params[:To].to_s
account = current_account
Rails.logger.info(
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
)
builder = Voice::InboundCallBuilder.new(
account: account,
inbox: @inbox,
from_number: from_number,
to_number: to_number,
call_sid: call_sid
).perform
render xml: builder.twiml_response
conversation = resolve_conversation
conference_sid = ensure_conference_sid!(conversation)
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
end
def conference_status
event = mapped_conference_event
return head :no_content unless event
conversation = find_conversation_for_conference!(
friendly_name: params[:FriendlyName],
call_sid: twilio_call_sid
)
Voice::Conference::Manager.new(
conversation: conversation,
event: event,
call_sid: twilio_call_sid,
participant_label: participant_label
).process
head :no_content
end
private
def twilio_call_sid
params[:CallSid]
end
def twilio_from
params[:From].to_s
end
def twilio_to
params[:To]
end
def twilio_direction
@twilio_direction ||= (params['Direction'] || params['CallDirection']).to_s
end
def mapped_conference_event
event = params[:StatusCallbackEvent].to_s
CONFERENCE_EVENT_PATTERNS.each do |pattern, mapped|
return mapped if event.match?(pattern)
end
nil
end
def agent_leg?(from_number)
from_number.start_with?('client:')
end
def resolve_conversation
return find_conversation_for_agent if agent_leg?(twilio_from)
case twilio_direction
when 'inbound'
Voice::InboundCallBuilder.perform!(
account: current_account,
inbox: inbox,
from_number: twilio_from,
call_sid: twilio_call_sid
)
when 'outbound-api', 'outbound-dial'
sync_outbound_leg(
call_sid: twilio_call_sid,
from_number: twilio_from,
direction: twilio_direction
)
else
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
end
end
def find_conversation_for_agent
if params[:conversation_id].present?
current_account.conversations.find_by!(display_id: params[:conversation_id])
else
current_account.conversations.find_by!(identifier: twilio_call_sid)
end
end
def sync_outbound_leg(call_sid:, from_number:, direction:)
parent_sid = params['ParentCallSid'].presence
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
conversation = current_account.conversations.find_by!(identifier: lookup_sid)
Voice::CallSessionSyncService.new(
conversation: conversation,
call_sid: call_sid,
message_call_sid: conversation.identifier,
leg: {
from_number: from_number,
to_number: twilio_to,
direction: 'outbound'
}
).perform
end
def ensure_conference_sid!(conversation)
attrs = conversation.additional_attributes || {}
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
attrs['conference_sid']
end
def conference_twiml(conference_sid, agent_leg)
Twilio::TwiML::VoiceResponse.new.tap do |response|
response.dial do |dial|
dial.conference(
conference_sid,
start_conference_on_enter: agent_leg,
end_conference_on_exit: false,
status_callback: conference_status_callback_url,
status_callback_event: 'start end join leave',
status_callback_method: 'POST',
participant_label: agent_leg ? 'agent' : 'contact'
)
end
end.to_s
end
def conference_status_callback_url
host = ENV.fetch('FRONTEND_URL', '')
phone_digits = inbox_channel.phone_number.delete_prefix('+')
"#{host}/twilio/voice/conference_status/#{phone_digits}"
end
def find_conversation_for_conference!(friendly_name:, call_sid:)
name = friendly_name.to_s
scope = current_account.conversations
if name.present?
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
return conversation if conversation
end
scope.find_by!(identifier: call_sid)
end
def set_inbox!
# Resolve from the digits in the route param and look up exact E.164 match
digits = params[:phone].to_s.gsub(/\D/, '')
e164 = "+#{digits}"
channel = Channel::Voice.find_by!(phone_number: e164)
@inbox = channel.inbox
end
def current_account
@current_account ||= inbox_account
end
def participant_label
params[:ParticipantLabel].to_s
end
attr_reader :inbox
delegate :account, :channel, to: :inbox, prefix: true
end
+62 -78
View File
@@ -1,94 +1,78 @@
module Captain::ChatHelper
include Integrations::LlmInstrumentation
include Captain::ToolExecutionHelper
def request_chat_completion
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
}
)
handle_response(response)
with_agent_session do
response = instrument_llm_call(instrumentation_params) do
@client.chat(
parameters: chat_parameters
)
end
handle_response(response)
end
rescue StandardError => e
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
raise e
end
def instrumentation_params
{
span_name: "llm.captain.#{feature_name}",
account_id: resolved_account_id,
conversation_id: @conversation_id,
feature_name: feature_name,
model: @model,
messages: @messages,
temperature: temperature,
metadata: {
assistant_id: @assistant&.id
}
}
end
def chat_parameters
{
model: @model,
messages: @messages,
tools: @tool_registry&.registered_tools || [],
response_format: { type: 'json_object' },
temperature: temperature
}
end
def temperature
@assistant&.config&.[]('temperature').to_f || 1
end
def resolved_account_id
@account&.id || @assistant&.account_id
end
private
def handle_response(response)
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
message = response.dig('choices', 0, 'message')
if message['tool_calls']
process_tool_calls(message['tool_calls'])
else
message = JSON.parse(message['content'].strip)
persist_message(message, 'assistant')
message
end
# Ensures all LLM calls and tool executions within an agentic loop
# are grouped under a single trace/session in Langfuse.
#
# Without this guard, each recursive call to request_chat_completion
# (triggered by tool calls) would create a separate trace instead of
# nesting within the existing session span.
def with_agent_session(&)
already_active = @agent_session_active
return yield if already_active
@agent_session_active = true
instrument_agent_session(instrumentation_params, &)
ensure
@agent_session_active = false unless already_active
end
def process_tool_calls(tool_calls)
append_tool_calls(tool_calls)
tool_calls.each do |tool_call|
process_tool_call(tool_call)
end
request_chat_completion
end
def process_tool_call(tool_call)
arguments = JSON.parse(tool_call['function']['arguments'])
function_name = tool_call['function']['name']
tool_call_id = tool_call['id']
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
def execute_tool(function_name, arguments, tool_call_id)
persist_message(
{
content: I18n.t('captain.copilot.using_tool', function_name: function_name),
function_name: function_name
},
'assistant_thinking'
)
result = @tool_registry.send(function_name, arguments)
persist_message(
{
content: I18n.t('captain.copilot.completed_tool_call', function_name: function_name),
function_name: function_name
},
'assistant_thinking'
)
append_tool_response(result, tool_call_id)
end
def append_tool_calls(tool_calls)
@messages << {
role: 'assistant',
tool_calls: tool_calls
}
end
def process_invalid_tool_call(function_name, tool_call_id)
persist_message({ content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name }, 'assistant_thinking')
append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id)
end
def append_tool_response(content, tool_call_id)
@messages << {
role: 'tool',
tool_call_id: tool_call_id,
content: content
}
# Must be implemented by including class to identify the feature for instrumentation.
# Used for Langfuse tagging and span naming.
def feature_name
raise NotImplementedError, "#{self.class.name} must implement #feature_name"
end
def log_chat_completion_request
@@ -0,0 +1,83 @@
module Captain::ToolExecutionHelper
private
def handle_response(response)
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
message = response.dig('choices', 0, 'message')
if message['tool_calls']
process_tool_calls(message['tool_calls'])
else
message = JSON.parse(message['content'].strip)
persist_message(message, 'assistant')
message
end
end
def process_tool_calls(tool_calls)
append_tool_calls(tool_calls)
tool_calls.each { |tool_call| process_tool_call(tool_call) }
request_chat_completion
end
def process_tool_call(tool_call)
arguments = JSON.parse(tool_call['function']['arguments'])
function_name = tool_call['function']['name']
tool_call_id = tool_call['id']
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
def execute_tool(function_name, arguments, tool_call_id)
persist_tool_status(function_name, 'captain.copilot.using_tool')
result = perform_tool_call(function_name, arguments)
persist_tool_status(function_name, 'captain.copilot.completed_tool_call')
append_tool_response(result, tool_call_id)
end
def perform_tool_call(function_name, arguments)
instrument_tool_call(function_name, arguments) do
@tool_registry.send(function_name, arguments)
end
rescue StandardError => e
Rails.logger.error "Tool #{function_name} failed: #{e.message}"
"Error executing #{function_name}: #{e.message}"
end
def persist_tool_status(function_name, translation_key)
persist_message(
{
content: I18n.t(translation_key, function_name: function_name),
function_name: function_name
},
'assistant_thinking'
)
end
def append_tool_calls(tool_calls)
@messages << {
role: 'assistant',
tool_calls: tool_calls
}
end
def process_invalid_tool_call(function_name, tool_call_id)
persist_message(
{ content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name },
'assistant_thinking'
)
append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id)
end
def append_tool_response(content, tool_call_id)
@messages << {
role: 'tool',
tool_call_id: tool_call_id,
content: content
}
end
end
@@ -30,7 +30,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
delegate :account, :inbox, to: :@conversation
def generate_and_process_response
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation_id: @conversation.id).generate_response(
message_history: collect_previous_messages
)
process_response
@@ -15,11 +15,14 @@ class Captain::Copilot::ResponseJob < ApplicationJob
private
def generate_chat_response(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:)
Captain::Copilot::ChatService.new(
service = Captain::Copilot::ChatService.new(
assistant,
user_id: user_id,
copilot_thread_id: copilot_thread_id,
conversation_id: conversation_id
).generate_response(message)
)
# When using copilot_thread, message is already in previous_history
# Pass nil to avoid duplicate
service.generate_response(copilot_thread_id.present? ? nil : message)
end
end
+22
View File
@@ -42,6 +42,17 @@ class Channel::Voice < ApplicationRecord
false
end
def initiate_call(to:)
case provider
when 'twilio'
Voice::Provider::TwilioAdapter.new(self).initiate_call(
to: to
)
else
raise "Unsupported voice provider: #{provider}"
end
end
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
@@ -76,6 +87,15 @@ class Channel::Voice < ApplicationRecord
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
end
end
# twilio_client and initiate_twilio_call moved to Voice::Provider::TwilioAdapter
def provider_config_hash
if provider_config.is_a?(Hash)
provider_config
else
JSON.parse(provider_config.to_s)
end
end
def provision_twilio_on_create
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
@@ -96,4 +116,6 @@ class Channel::Voice < ApplicationRecord
Rails.logger.error("TWILIO_VOICE_SETUP_ON_CREATE_ERROR: #{error_details}")
errors.add(:base, "Twilio setup failed: #{e.message}")
end
public :provider_config_hash
end
@@ -13,6 +13,7 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
@user = nil
@copilot_thread = nil
@previous_history = []
@conversation_id = config[:conversation_id]
setup_user(config)
setup_message_history(config)
register_tools
@@ -113,4 +114,8 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
message_type: message_type
)
end
def feature_name
'copilot'
end
end
@@ -3,10 +3,11 @@ require 'openai'
class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
include Captain::ChatHelper
def initialize(assistant: nil)
def initialize(assistant: nil, conversation_id: nil)
super()
@assistant = assistant
@conversation_id = conversation_id
@messages = [system_message]
@response = ''
register_tools
@@ -42,4 +43,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
def persist_message(message, message_type = 'assistant')
# No need to implement
end
def feature_name
'assistant'
end
end
@@ -0,0 +1,16 @@
module Enterprise::Contacts::ContactableInboxesService
private
# Extend base selection to include Voice inboxes
def get_contactable_inbox(inbox)
return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Voice'
super
end
def voice_contactable_inbox(inbox)
return if @contact.phone_number.blank?
{ source_id: @contact.phone_number, inbox: inbox }
end
end
@@ -0,0 +1,90 @@
class Voice::CallMessageBuilder
def self.perform!(conversation:, direction:, payload:, user: nil, timestamps: {})
new(
conversation: conversation,
direction: direction,
payload: payload,
user: user,
timestamps: timestamps
).perform!
end
def initialize(conversation:, direction:, payload:, user:, timestamps:)
@conversation = conversation
@direction = direction
@payload = payload
@user = user
@timestamps = timestamps
end
def perform!
validate_sender!
message = latest_message
message ? update_message!(message) : create_message!
end
private
attr_reader :conversation, :direction, :payload, :user, :timestamps
def latest_message
conversation.messages.voice_calls.order(created_at: :desc).first
end
def update_message!(message)
message.update!(
message_type: message_type,
content_attributes: { 'data' => base_payload },
sender: sender
)
end
def create_message!
params = {
content: 'Voice Call',
message_type: message_type,
content_type: 'voice_call',
content_attributes: { 'data' => base_payload }
}
Messages::MessageBuilder.new(sender, conversation, params).perform
end
def base_payload
@base_payload ||= begin
data = payload.slice(
:call_sid,
:status,
:call_direction,
:conference_sid,
:from_number,
:to_number
).stringify_keys
data['call_direction'] = direction
data['meta'] = {
'created_at' => timestamps[:created_at] || current_timestamp,
'ringing_at' => timestamps[:ringing_at] || current_timestamp
}.compact
data
end
end
def message_type
direction == 'outbound' ? 'outgoing' : 'incoming'
end
def sender
return user if direction == 'outbound'
conversation.contact
end
def validate_sender!
return unless direction == 'outbound'
raise ArgumentError, 'Agent sender required for outbound calls' unless user
end
def current_timestamp
@current_timestamp ||= Time.zone.now.to_i
end
end
@@ -0,0 +1,94 @@
class Voice::CallSessionSyncService
attr_reader :conversation, :call_sid, :message_call_sid, :from_number, :to_number, :direction
def initialize(conversation:, call_sid:, leg:, message_call_sid: nil)
@conversation = conversation
@call_sid = call_sid
@message_call_sid = message_call_sid || call_sid
@from_number = leg[:from_number]
@to_number = leg[:to_number]
@direction = leg[:direction]
end
def perform
ActiveRecord::Base.transaction do
attrs = refreshed_attributes
conversation.update!(
additional_attributes: attrs,
last_activity_at: current_time
)
sync_voice_call_message!(attrs)
end
conversation
end
private
def refreshed_attributes
attrs = (conversation.additional_attributes || {}).dup
attrs['call_direction'] = direction
attrs['call_status'] ||= 'ringing'
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
attrs['meta'] ||= {}
attrs['meta']['initiated_at'] ||= current_timestamp
attrs
end
def sync_voice_call_message!(attrs)
Voice::CallMessageBuilder.perform!(
conversation: conversation,
direction: direction,
payload: {
call_sid: message_call_sid,
status: attrs['call_status'],
conference_sid: attrs['conference_sid'],
from_number: origin_number_for(direction),
to_number: target_number_for(direction)
},
user: agent_for(attrs),
timestamps: {
created_at: attrs.dig('meta', 'initiated_at'),
ringing_at: attrs.dig('meta', 'ringing_at')
}
)
end
def origin_number_for(current_direction)
return outbound_origin if current_direction == 'outbound'
from_number.presence || inbox_number
end
def target_number_for(current_direction)
return conversation.contact&.phone_number || to_number if current_direction == 'outbound'
to_number || conversation.contact&.phone_number
end
def agent_for(attrs)
agent_id = attrs['agent_id']
return nil unless agent_id
agent = conversation.account.users.find_by(id: agent_id)
raise ArgumentError, 'Agent sender required for outbound call sync' if direction == 'outbound' && agent.nil?
agent
end
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
end
def outbound_origin
inbox_number || from_number
end
def inbox_number
conversation.inbox&.channel&.phone_number
end
end
@@ -0,0 +1,66 @@
class Voice::CallStatus::Manager
pattr_initialize [:conversation!, :call_sid]
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
TERMINAL_STATUSES = %w[completed no-answer failed].freeze
def process_status_update(status, duration: nil, timestamp: nil)
return unless ALLOWED_STATUSES.include?(status)
current_status = conversation.additional_attributes&.dig('call_status')
return if current_status == status
apply_status(status, duration: duration, timestamp: timestamp)
update_message(status)
end
private
def apply_status(status, duration:, timestamp:)
attrs = (conversation.additional_attributes || {}).dup
attrs['call_status'] = status
if status == 'in-progress'
attrs['call_started_at'] ||= timestamp || now_seconds
elsif TERMINAL_STATUSES.include?(status)
attrs['call_ended_at'] = timestamp || now_seconds
attrs['call_duration'] = resolved_duration(attrs, duration, timestamp)
end
conversation.update!(
additional_attributes: attrs,
last_activity_at: current_time
)
end
def resolved_duration(attrs, provided_duration, timestamp)
return provided_duration if provided_duration
started_at = attrs['call_started_at']
return unless started_at && timestamp
[timestamp - started_at.to_i, 0].max
end
def update_message(status)
message = conversation.messages
.where(content_type: 'voice_call')
.order(created_at: :desc)
.first
return unless message
data = (message.content_attributes || {}).dup
data['data'] ||= {}
data['data']['status'] = status
message.update!(content_attributes: data)
end
def now_seconds
current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
end
end
@@ -0,0 +1,71 @@
class Voice::Conference::Manager
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
def process
case event
when 'start'
ensure_conference_sid!
mark_ringing!
when 'join'
mark_in_progress! if agent_participant?
when 'leave'
handle_leave!
when 'end'
finalize_conference!
end
end
private
def status_manager
@status_manager ||= Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: call_sid
)
end
def ensure_conference_sid!
attrs = conversation.additional_attributes || {}
return if attrs['conference_sid'].present?
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
end
def mark_ringing!
return if current_status
status_manager.process_status_update('ringing')
end
def mark_in_progress!
status_manager.process_status_update('in-progress', timestamp: current_timestamp)
end
def handle_leave!
case current_status
when 'ringing'
status_manager.process_status_update('no-answer', timestamp: current_timestamp)
when 'in-progress'
status_manager.process_status_update('completed', timestamp: current_timestamp)
end
end
def finalize_conference!
return if %w[completed no-answer failed].include?(current_status)
status_manager.process_status_update('completed', timestamp: current_timestamp)
end
def current_status
conversation.additional_attributes&.dig('call_status')
end
def agent_participant?
participant_label.to_s.start_with?('agent')
end
def current_timestamp
Time.zone.now.to_i
end
end
@@ -0,0 +1,5 @@
module Voice::Conference::Name
def self.for(conversation)
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
end
end
@@ -1,82 +1,99 @@
class Voice::InboundCallBuilder
pattr_initialize [:account!, :inbox!, :from_number!, :to_number, :call_sid!]
attr_reader :account, :inbox, :from_number, :call_sid
attr_reader :conversation
def perform
contact = find_or_create_contact!
contact_inbox = find_or_create_contact_inbox!(contact)
@conversation = find_or_create_conversation!(contact, contact_inbox)
create_call_message_if_needed!
self
def self.perform!(account:, inbox:, from_number:, call_sid:)
new(account: account, inbox: inbox, from_number: from_number, call_sid: call_sid).perform!
end
def twiml_response
response = Twilio::TwiML::VoiceResponse.new
response.say(message: 'Please wait while we connect you to an agent')
response.to_s
def initialize(account:, inbox:, from_number:, call_sid:)
@account = account
@inbox = inbox
@from_number = from_number
@call_sid = call_sid
end
def perform!
timestamp = current_timestamp
ActiveRecord::Base.transaction do
contact = ensure_contact!
contact_inbox = ensure_contact_inbox!(contact)
conversation = find_conversation || create_conversation!(contact, contact_inbox)
conversation.reload
update_conversation!(conversation, timestamp)
build_voice_message!(conversation, timestamp)
conversation
end
end
private
def find_or_create_conversation!(contact, contact_inbox)
account.conversations.find_or_create_by!(
account_id: account.id,
inbox_id: inbox.id,
identifier: call_sid
) do |conv|
conv.contact_id = contact.id
conv.contact_inbox_id = contact_inbox.id
conv.additional_attributes = {
'call_direction' => 'inbound',
'call_status' => 'ringing'
}
def ensure_contact!
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
record.name = from_number if record.name.blank?
end
end
def create_call_message!
content_attrs = call_message_content_attributes
def ensure_contact_inbox!(contact)
ContactInbox.find_or_create_by!(
contact_id: contact.id,
inbox_id: inbox.id
) do |record|
record.source_id = from_number
end
end
@conversation.messages.create!(
account_id: account.id,
def find_conversation
return if call_sid.blank?
account.conversations.includes(:contact).find_by(identifier: call_sid)
end
def create_conversation!(contact, contact_inbox)
account.conversations.create!(
contact_inbox_id: contact_inbox.id,
inbox_id: inbox.id,
message_type: :incoming,
sender: @conversation.contact,
content: 'Voice Call',
content_type: 'voice_call',
content_attributes: content_attrs
contact_id: contact.id,
status: :open,
identifier: call_sid
)
end
def create_call_message_if_needed!
return if @conversation.messages.voice_calls.exists?
def update_conversation!(conversation, timestamp)
attrs = {
'call_direction' => 'inbound',
'call_status' => 'ringing',
'conference_sid' => Voice::Conference::Name.for(conversation),
'meta' => { 'initiated_at' => timestamp }
}
create_call_message!
conversation.update!(
identifier: call_sid,
additional_attributes: attrs,
last_activity_at: current_time
)
end
def call_message_content_attributes
{
data: {
def build_voice_message!(conversation, timestamp)
Voice::CallMessageBuilder.perform!(
conversation: conversation,
direction: 'inbound',
payload: {
call_sid: call_sid,
status: 'ringing',
conversation_id: @conversation.display_id,
call_direction: 'inbound',
conference_sid: conversation.additional_attributes['conference_sid'],
from_number: from_number,
to_number: to_number,
meta: {
created_at: Time.current.to_i,
ringing_at: Time.current.to_i
}
}
}
to_number: inbox.channel&.phone_number
},
timestamps: { created_at: timestamp, ringing_at: timestamp }
)
end
def find_or_create_contact!
account.contacts.find_by(phone_number: from_number) ||
account.contacts.create!(phone_number: from_number, name: 'Unknown Caller')
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def find_or_create_contact_inbox!(contact)
ContactInbox.where(contact_id: contact.id, inbox_id: inbox.id, source_id: from_number).first_or_create!
def current_time
@current_time ||= Time.zone.now
end
end
@@ -0,0 +1,98 @@
class Voice::OutboundCallBuilder
attr_reader :account, :inbox, :user, :contact
def self.perform!(account:, inbox:, user:, contact:)
new(account: account, inbox: inbox, user: user, contact: contact).perform!
end
def initialize(account:, inbox:, user:, contact:)
@account = account
@inbox = inbox
@user = user
@contact = contact
end
def perform!
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
raise ArgumentError, 'Agent required' if user.blank?
timestamp = current_timestamp
ActiveRecord::Base.transaction do
contact_inbox = ensure_contact_inbox!
conversation = create_conversation!(contact_inbox)
conversation.reload
conference_sid = Voice::Conference::Name.for(conversation)
call_sid = initiate_call!
update_conversation!(conversation, call_sid, conference_sid, timestamp)
build_voice_message!(conversation, call_sid, conference_sid, timestamp)
{ conversation: conversation, call_sid: call_sid }
end
end
private
def ensure_contact_inbox!
ContactInbox.find_or_create_by!(
contact_id: contact.id,
inbox_id: inbox.id
) do |record|
record.source_id = contact.phone_number
end
end
def create_conversation!(contact_inbox)
account.conversations.create!(
contact_inbox_id: contact_inbox.id,
inbox_id: inbox.id,
contact_id: contact.id,
status: :open
)
end
def initiate_call!
inbox.channel.initiate_call(
to: contact.phone_number
)[:call_sid]
end
def update_conversation!(conversation, call_sid, conference_sid, timestamp)
attrs = {
'call_direction' => 'outbound',
'call_status' => 'ringing',
'agent_id' => user.id,
'conference_sid' => conference_sid,
'meta' => { 'initiated_at' => timestamp }
}
conversation.update!(
identifier: call_sid,
additional_attributes: attrs,
last_activity_at: current_time
)
end
def build_voice_message!(conversation, call_sid, conference_sid, timestamp)
Voice::CallMessageBuilder.perform!(
conversation: conversation,
direction: 'outbound',
payload: {
call_sid: call_sid,
status: 'ringing',
conference_sid: conference_sid,
from_number: inbox.channel&.phone_number,
to_number: contact.phone_number
},
user: user,
timestamps: { created_at: timestamp, ringing_at: timestamp }
)
end
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
end
end
@@ -0,0 +1,32 @@
class Voice::Provider::TwilioAdapter
def initialize(channel)
@channel = channel
end
def initiate_call(to:, _conference_sid: nil, _agent_id: nil)
cfg = @channel.provider_config_hash
host = ENV.fetch('FRONTEND_URL')
phone_digits = @channel.phone_number.delete_prefix('+')
callback_url = "#{host}/twilio/voice/call/#{phone_digits}"
params = {
from: @channel.phone_number,
to: to,
url: callback_url,
status_callback: "#{host}/twilio/voice/status/#{phone_digits}",
status_callback_event: %w[initiated ringing answered completed],
status_callback_method: 'POST'
}
call = twilio_client(cfg).calls.create(**params)
{ call_sid: call.sid }
end
private
def twilio_client(config)
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
end
@@ -1,29 +1,60 @@
class Voice::StatusUpdateService
pattr_initialize [:account!, :call_sid!, :call_status]
pattr_initialize [:account!, :call_sid!, :call_status, { payload: {} }]
TWILIO_STATUS_MAP = {
'queued' => 'ringing',
'initiated' => 'ringing',
'ringing' => 'ringing',
'in-progress' => 'in-progress',
'inprogress' => 'in-progress',
'answered' => 'in-progress',
'completed' => 'completed',
'busy' => 'no-answer',
'no-answer' => 'no-answer',
'failed' => 'failed',
'canceled' => 'failed'
}.freeze
def perform
normalized_status = normalize_status(call_status)
return if normalized_status.blank?
conversation = account.conversations.find_by(identifier: call_sid)
return unless conversation
return if call_status.to_s.strip.empty?
update_conversation!(conversation)
update_last_call_message!(conversation)
Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: call_sid
).process_status_update(
normalized_status,
duration: payload_duration,
timestamp: payload_timestamp
)
end
private
def update_conversation!(conversation)
attrs = (conversation.additional_attributes || {}).merge('call_status' => call_status)
conversation.update!(additional_attributes: attrs)
def normalize_status(status)
return if status.to_s.strip.empty?
TWILIO_STATUS_MAP[status.to_s.downcase]
end
def update_last_call_message!(conversation)
msg = conversation.messages.voice_calls.order(created_at: :desc).first
return unless msg
def payload_duration
return unless payload.is_a?(Hash)
data = msg.content_attributes.is_a?(Hash) ? msg.content_attributes : {}
data['data'] ||= {}
data['data']['status'] = call_status
msg.update!(content_attributes: data)
duration = payload['CallDuration'] || payload['call_duration']
duration&.to_i
end
def payload_timestamp
return unless payload.is_a?(Hash)
ts = payload['Timestamp'] || payload['timestamp']
return unless ts
Time.zone.parse(ts).to_i
rescue ArgumentError
nil
end
end
+7
View File
@@ -42,4 +42,11 @@ module ChatwootApp
def self.advanced_search_allowed?
enterprise? && ENV.fetch('OPENSEARCH_URL', nil).present?
end
def self.otel_enabled?
otel_provider = InstallationConfig.find_by(name: 'OTEL_PROVIDER')&.value
secret_key = InstallationConfig.find_by(name: 'LANGFUSE_SECRET_KEY')&.value
otel_provider.present? && secret_key.present? && otel_provider == 'langfuse'
end
end
+131
View File
@@ -0,0 +1,131 @@
# frozen_string_literal: true
require 'opentelemetry_config'
require_relative 'llm_instrumentation_constants'
module Integrations::LlmInstrumentation
include Integrations::LlmInstrumentationConstants
def tracer
@tracer ||= OpentelemetryConfig.tracer
end
def instrument_llm_call(params)
return yield unless ChatwootApp.otel_enabled?
tracer.in_span(params[:span_name]) do |span|
setup_span_attributes(span, params)
result = yield
record_completion(span, result)
result
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: params[:account]).capture_exception
yield
end
def instrument_agent_session(params)
return yield unless ChatwootApp.otel_enabled?
tracer.in_span(params[:span_name]) do |span|
set_metadata_attributes(span, params)
# By default, the input and output of a trace are set from the root observation
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
result = yield
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
result
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: params[:account]).capture_exception
yield
end
def instrument_tool_call(tool_name, arguments)
# There is no error handling because tools can fail and LLMs should be
# aware of those failures and factor them into their response.
return yield unless ChatwootApp.otel_enabled?
tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json)
result = yield
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
result
end
end
private
def setup_span_attributes(span, params)
set_request_attributes(span, params)
set_prompt_messages(span, params[:messages])
set_metadata_attributes(span, params)
end
def record_completion(span, result)
set_completion_attributes(span, result) if result.is_a?(Hash)
end
def set_request_attributes(span, params)
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
end
def set_prompt_messages(span, messages)
messages.each_with_index do |msg, idx|
role = msg[:role] || msg['role']
content = msg[:content] || msg['content']
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), role)
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s)
end
end
def set_metadata_attributes(span, params)
session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present?
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
return unless params[:metadata].is_a?(Hash)
params[:metadata].each do |key, value|
span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s)
end
end
def set_completion_attributes(span, result)
set_completion_message(span, result)
set_usage_metrics(span, result)
set_error_attributes(span, result)
end
def set_completion_message(span, result)
message = result[:message] || result.dig('choices', 0, 'message', 'content')
return if message.blank?
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
end
def set_usage_metrics(span, result)
usage = result[:usage] || result['usage']
return if usage.blank?
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
end
def set_error_attributes(span, result)
error = result[:error] || result['error']
return if error.blank?
error_code = result[:error_code] || result['error_code']
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, error_code) if error_code
span.status = OpenTelemetry::Trace::Status.error("API Error: #{error_code}")
end
end
@@ -0,0 +1,31 @@
# frozen_string_literal: true
module Integrations::LlmInstrumentationConstants
# OpenTelemetry attribute names following GenAI semantic conventions
# https://opentelemetry.io/docs/specs/semconv/gen-ai/
ATTR_GEN_AI_PROVIDER = 'gen_ai.provider.name'
ATTR_GEN_AI_REQUEST_MODEL = 'gen_ai.request.model'
ATTR_GEN_AI_REQUEST_TEMPERATURE = 'gen_ai.request.temperature'
ATTR_GEN_AI_PROMPT_ROLE = 'gen_ai.prompt.%d.role'
ATTR_GEN_AI_PROMPT_CONTENT = 'gen_ai.prompt.%d.content'
ATTR_GEN_AI_COMPLETION_ROLE = 'gen_ai.completion.0.role'
ATTR_GEN_AI_COMPLETION_CONTENT = 'gen_ai.completion.0.content'
ATTR_GEN_AI_USAGE_INPUT_TOKENS = 'gen_ai.usage.input_tokens'
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = 'gen_ai.usage.output_tokens'
ATTR_GEN_AI_USAGE_TOTAL_TOKENS = 'gen_ai.usage.total_tokens'
ATTR_GEN_AI_RESPONSE_ERROR = 'gen_ai.response.error'
ATTR_GEN_AI_RESPONSE_ERROR_CODE = 'gen_ai.response.error_code'
TOOL_SPAN_NAME = 'tool.%s'
# Langfuse-specific attributes
# https://langfuse.com/integrations/native/opentelemetry#property-mapping
ATTR_LANGFUSE_USER_ID = 'langfuse.user.id'
ATTR_LANGFUSE_SESSION_ID = 'langfuse.session.id'
ATTR_LANGFUSE_TAGS = 'langfuse.trace.tags'
ATTR_LANGFUSE_METADATA = 'langfuse.trace.metadata.%s'
ATTR_LANGFUSE_TRACE_INPUT = 'langfuse.trace.input'
ATTR_LANGFUSE_TRACE_OUTPUT = 'langfuse.trace.output'
ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input'
ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output'
end
+46 -8
View File
@@ -1,4 +1,6 @@
class Integrations::OpenaiBaseService
include Integrations::LlmInstrumentation
# gpt-4o-mini supports 128,000 tokens
# 1 token is approx 4 characters
# sticking with 120000 to be safe
@@ -87,21 +89,57 @@ class Integrations::OpenaiBaseService
end
def make_api_call(body)
headers = {
parsed_body = JSON.parse(body)
instrumentation_params = build_instrumentation_params(parsed_body)
instrument_llm_call(instrumentation_params) do
execute_api_request(body, parsed_body['messages'])
end
end
def build_instrumentation_params(parsed_body)
{
span_name: "llm.#{event_name}",
account_id: hook.account_id,
conversation_id: conversation&.display_id,
feature_name: event_name,
model: parsed_body['model'],
messages: parsed_body['messages'],
temperature: parsed_body['temperature']
}
end
def execute_api_request(body, messages)
Rails.logger.info("OpenAI API request: #{body}")
response = HTTParty.post(api_url, headers: api_headers, body: body)
Rails.logger.info("OpenAI API response: #{response.body}")
parse_api_response(response, messages)
end
def api_headers
{
'Content-Type' => 'application/json',
'Authorization' => "Bearer #{hook.settings['api_key']}"
}
end
Rails.logger.info("OpenAI API request: #{body}")
response = HTTParty.post(api_url, headers: headers, body: body)
Rails.logger.info("OpenAI API response: #{response.body}")
def parse_api_response(response, messages)
return build_error_response(response, messages) unless response.success?
return { error: response.parsed_response, error_code: response.code } unless response.success?
parsed_response = JSON.parse(response.body)
build_success_response(parsed_response, messages)
end
choices = JSON.parse(response.body)['choices']
def build_error_response(response, messages)
{ error: response.parsed_response, error_code: response.code, request_messages: messages }
end
return { message: choices.first['message']['content'] } if choices.present?
def build_success_response(parsed_response, messages)
choices = parsed_response['choices']
usage = parsed_response['usage']
message_content = choices.present? ? choices.first['message']['content'] : nil
{ message: nil }
{ message: message_content, usage: usage, request_messages: messages }
end
end
+91
View File
@@ -0,0 +1,91 @@
require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'
require 'base64'
module OpentelemetryConfig
class << self
def tracer
initialize! unless initialized?
OpenTelemetry.tracer_provider.tracer('chatwoot')
end
def initialized?
@initialized ||= false
end
def initialize!
return if @initialized
return mark_initialized unless langfuse_provider?
return mark_initialized unless langfuse_credentials_present?
configure_opentelemetry
mark_initialized
rescue StandardError => e
Rails.logger.error "Failed to configure OpenTelemetry: #{e.message}"
mark_initialized
end
def reset!
@initialized = false
end
private
def mark_initialized
@initialized = true
end
def langfuse_provider?
otel_provider = InstallationConfig.find_by(name: 'OTEL_PROVIDER')&.value
otel_provider == 'langfuse'
end
def langfuse_credentials_present?
endpoint = InstallationConfig.find_by(name: 'LANGFUSE_BASE_URL')&.value
public_key = InstallationConfig.find_by(name: 'LANGFUSE_PUBLIC_KEY')&.value
secret_key = InstallationConfig.find_by(name: 'LANGFUSE_SECRET_KEY')&.value
if endpoint.blank? || public_key.blank? || secret_key.blank?
Rails.logger.error 'OpenTelemetry disabled (LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY or LANGFUSE_SECRET_KEY is missing)'
return false
end
true
end
def langfuse_credentials
{
endpoint: InstallationConfig.find_by(name: 'LANGFUSE_BASE_URL')&.value,
public_key: InstallationConfig.find_by(name: 'LANGFUSE_PUBLIC_KEY')&.value,
secret_key: InstallationConfig.find_by(name: 'LANGFUSE_SECRET_KEY')&.value
}
end
def traces_endpoint
credentials = langfuse_credentials
"#{credentials[:endpoint]}/api/public/otel/v1/traces"
end
def exporter_config
credentials = langfuse_credentials
auth_header = Base64.strict_encode64("#{credentials[:public_key]}:#{credentials[:secret_key]}")
config = {
endpoint: traces_endpoint,
headers: { 'Authorization' => "Basic #{auth_header}" }
}
config[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE if Rails.env.development?
config
end
def configure_opentelemetry
OpenTelemetry::SDK.configure do |c|
c.service_name = 'chatwoot'
exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(**exporter_config)
c.add_span_processor(OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter))
Rails.logger.info 'OpenTelemetry initialized and configured to export to Langfuse'
end
end
end
end
+2 -1
View File
@@ -79,8 +79,9 @@
"md5": "^2.3.0",
"mitt": "^3.0.1",
"opus-recorder": "^8.0.5",
"qrcode": "^1.5.4",
"pinia": "^3.0.4",
"posthog-js": "^1.260.2",
"qrcode": "^1.5.4",
"semver": "7.6.3",
"snakecase-keys": "^8.0.1",
"timezone-phone-codes": "^0.0.2",
+94
View File
@@ -157,6 +157,9 @@ importers:
opus-recorder:
specifier: ^8.0.5
version: 8.0.5
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2))
posthog-js:
specifier: ^1.260.2
version: 1.260.3
@@ -1371,6 +1374,15 @@ packages:
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/devtools-api@7.7.8':
resolution: {integrity: sha512-BtFcAmDbtXGwurWUFf8ogIbgZyR+rcVES1TSNEI8Em80fD8Anu+qTRN1Fc3J6vdRHlVM3fzPV1qIo+B4AiqGzw==}
'@vue/devtools-kit@7.7.8':
resolution: {integrity: sha512-4Y8op+AoxOJhB9fpcEF6d5vcJXWKgHxC3B0ytUB8zz15KbP9g9WgVzral05xluxi2fOeAy6t140rdQ943GcLRQ==}
'@vue/devtools-shared@7.7.8':
resolution: {integrity: sha512-XHpO3jC5nOgYr40M9p8Z4mmKfTvUxKyRcUnpBAYg11pE78eaRFBKb0kG5yKLroMuJeeNH9LWmKp2zMU5LUc7CA==}
'@vue/reactivity@3.5.12':
resolution: {integrity: sha512-UzaN3Da7xnJXdz4Okb/BGbAaomRHc3RdoWqTzlvd9+WBR5m3J39J1fGcHes7U3za0ruYn/iYy/a1euhMEHvTAg==}
@@ -1623,6 +1635,9 @@ packages:
birpc@0.1.1:
resolution: {integrity: sha512-B64AGL4ug2IS2jvV/zjTYDD1L+2gOJTT7Rv+VaK7KVQtQOo/xZbCDsh7g727ipckmU+QJYRqo5RcifVr0Kgcmg==}
birpc@2.8.0:
resolution: {integrity: sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
@@ -1827,6 +1842,10 @@ packages:
constant-case@3.0.4:
resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
core-js@3.38.1:
resolution: {integrity: sha512-OP35aUorbU3Zvlx7pjsFdu1rGNnD4pgw/CWoYzRY3t2EzoVT7shKHY1dlAy3f41cGIO7ZDPQimhGFTlEYkG/Hw==}
@@ -2607,6 +2626,9 @@ packages:
peerDependencies:
vite: 5.4.21
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
hotkeys-js@3.8.7:
resolution: {integrity: sha512-ckAx3EkUr5XjDwjEHDorHxRO2Kb7z6Z2Sxul4MbBkN8Nho7XDslQsgMJT+CiJ5Z4TgRxxvKHEpuLE3imzqy4Lg==}
@@ -2840,6 +2862,10 @@ packages:
is-weakref@1.0.2:
resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==}
is-what@5.5.0:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@@ -3443,6 +3469,9 @@ packages:
resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
engines: {node: '>= 14.16'}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
picocolors@1.0.1:
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
@@ -3465,6 +3494,15 @@ packages:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
pinia@3.0.4:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies:
typescript: '>=4.5.0'
vue: ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
pirates@4.0.6:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
@@ -3872,6 +3910,9 @@ packages:
rfdc@1.3.0:
resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
deprecated: Rimraf versions prior to v4 are no longer supported
@@ -4044,6 +4085,10 @@ packages:
spark-md5@3.0.2:
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
@@ -4130,6 +4175,10 @@ packages:
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
superjson@2.2.5:
resolution: {integrity: sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==}
engines: {node: '>=16'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -5842,6 +5891,24 @@ snapshots:
'@vue/devtools-api@6.6.4': {}
'@vue/devtools-api@7.7.8':
dependencies:
'@vue/devtools-kit': 7.7.8
'@vue/devtools-kit@7.7.8':
dependencies:
'@vue/devtools-shared': 7.7.8
birpc: 2.8.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
speakingurl: 14.0.1
superjson: 2.2.5
'@vue/devtools-shared@7.7.8':
dependencies:
rfdc: 1.4.1
'@vue/reactivity@3.5.12':
dependencies:
'@vue/shared': 3.5.12
@@ -6137,6 +6204,8 @@ snapshots:
birpc@0.1.1: {}
birpc@2.8.0: {}
boolbase@1.0.0: {}
boxen@8.0.1:
@@ -6383,6 +6452,10 @@ snapshots:
tslib: 2.8.1
upper-case: 2.0.2
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
core-js@3.38.1: {}
countries-and-timezones@3.6.0: {}
@@ -7341,6 +7414,8 @@ snapshots:
- terser
- utf-8-validate
hookable@5.5.3: {}
hotkeys-js@3.8.7: {}
html-encoding-sniffer@3.0.0:
@@ -7558,6 +7633,8 @@ snapshots:
dependencies:
call-bind: 1.0.7
is-what@5.5.0: {}
isarray@2.0.5: {}
isexe@2.0.0: {}
@@ -8215,6 +8292,8 @@ snapshots:
pathval@2.0.0: {}
perfect-debounce@1.0.0: {}
picocolors@1.0.1: {}
picocolors@1.1.0: {}
@@ -8227,6 +8306,13 @@ snapshots:
pify@2.3.0: {}
pinia@3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)):
dependencies:
'@vue/devtools-api': 7.7.8
vue: 3.5.12(typescript@5.6.2)
optionalDependencies:
typescript: 5.6.2
pirates@4.0.6: {}
pkcs7@1.0.4:
@@ -8686,6 +8772,8 @@ snapshots:
rfdc@1.3.0: {}
rfdc@1.4.1: {}
rimraf@3.0.2:
dependencies:
glob: 7.2.3
@@ -8897,6 +8985,8 @@ snapshots:
spark-md5@3.0.2: {}
speakingurl@14.0.1: {}
sprintf-js@1.0.3: {}
stackback@0.0.2: {}
@@ -8992,6 +9082,10 @@ snapshots:
pirates: 4.0.6
ts-interface-checker: 0.1.13
superjson@2.2.5:
dependencies:
copy-anything: 4.0.5
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0

Some files were not shown because too many files have changed in this diff Show More