Compare commits

...
Author SHA1 Message Date
Muhsin KelothandGitHub 3d708eedea Merge branch 'develop' into CW-1823 2023-05-31 12:54:36 +05:30
Sojan JoseandGitHub 373f5f5b64 chore: Disable coverage reports for .vue files (#7214)
- Disable coverage reporting for .vue files 

fixes: https://linear.app/chatwoot/issue/CW-1331
2023-05-30 19:18:19 +05:30
Sivin VargheseandGitHub ae7df60a75 fix: Special characters not being decoded (#7218) 2023-05-30 18:54:51 +05:30
412d750b6a feat: auditlogs design refactor cw1764 (#7181)
* chore: refactor auditlogs design

* chore: refactor aduit log text

* chore: fix 60% width for activity column

* chore: improve log text formatting

* Apply suggestions from code review

Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>

* feat: show agent names if available in auditlogs

* chore: add sign_out

* Apply suggestions from code review

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>

* chore: handle custom user actions

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-05-30 14:41:29 +05:30
f1a77ba934 feat: allow superadmins to reset cache keys for IndexedDB (#7180)
Allows super admins to reset the cache for an account. This will force the front end to fetch the data again on the next load.

fixes: https://linear.app/chatwoot/issue/CW-1817

Co-authored-by: Sojan <sojan@pepalo.com>
2023-05-29 23:05:17 +05:30
Sojan JoseandGitHub ffc6364690 chore: Improve Whatsapp Templates Sync (#7210)
- update the templates updated at, even if the API request fails ( to prevent jobs from stacking up in case of API failures upstream )
- sequence the job in batches of 25 requests per minutes schedule ( in case API response time is high, also not to send too many requests in a single batch )
- move the sync job re-rerun to 3 hours ( since we are updating the updated at even in case of failures )(prev 15 minutes )

Fixes: https://linear.app/chatwoot/issue/CW-1590
2023-05-29 22:23:22 +05:30
Sojan JoseandGitHub 3a7633b564 feat: API to create HMAC verified conversations (#7209)
Fixes: #6744
2023-05-29 21:57:24 +05:30
amplitudesandGitHub 91d1d44aa9 fix: profile picture distortion (#7203)
By applying pr-1 on a container div instead of directly on the image (which distorts it), it creates the intended padding effect without any image distortion.
2023-05-29 15:00:14 +05:30
Muhsin Keloth c3e90d4cbc Fix custom attribute date value is not working properly 2023-05-29 00:22:19 -07:00
Sojan JoseandGitHub afc97faa8b chore: Rotate pubsub token on password change (#7194)
Fixes: https://linear.app/chatwoot/issue/CW-1350/
2023-05-27 13:24:31 +05:30
1d0930ef63 fix: Updates editor to fix the extra slash issue [cw-1505][cw-1526] (#7091)
* fix: Updates editor to fix the extra slash issue

* Updates yarn package

* Updates yarn package for editor schema

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-05-26 00:54:37 +05:30
Tejaswini ChileandGitHub 776ea6d39a fix: Help center api doc (#7189) 2023-05-25 18:50:00 +05:30
Muhsin KelothandGitHub 2c85098698 fix: Mentions in assigned/participation notification(#7185) 2023-05-25 18:18:56 +05:30
Sivin VargheseandGitHub 6bd0e074dc feat: Sort agents on availability status (#7174) 2023-05-25 14:49:56 +05:30
Vishnu NarayananandGitHub 123fc73394 feat: add audit trail for sign_in and sign_out (#7158)
* feat: add audit_trail for sign_in event

* chore: ignore unrelated User model columns for auditing

* chore: fix prepend call for webhook/automation rule

* chore: add spec for sign_in event

* chore: refactor sign_in auditlog method to enterprise namespace

* feat: add sign_out audit trail

* feat: review comments
2023-05-25 14:27:30 +05:30
88cef88d80 fix: Instagram story with video not rendering (#7184)
* fix: instagram story with video not rendering

* chore: Minor fixes

---------

Co-authored-by: Tejaswini Chile <tejaswini@chatwoot.com>
2023-05-25 12:20:42 +05:30
Tejaswini ChileandGitHub 8e79cf72e1 fix: issue with slack job (#7179) 2023-05-24 20:22:34 +05:30
Sojan JoseandGitHub bfaca851f1 chore: Throttle conversation transcript endpoints (#7155)
- Throttle conversation transcripts to 20 per hour via rack attack

Fixes: https://linear.app/chatwoot/issue/CW-1630/throttle-transcript-endpoint
2023-05-24 19:56:23 +05:30
Muhsin KelothandGitHub 5618b5ebd3 fix: Enable ogg format for audio recording in API channel (#7178) 2023-05-24 15:05:31 +05:30
Muhsin KelothandGitHub 4807052c37 doc: Swagger for conversation priority API (#7172) 2023-05-23 20:39:15 +05:30
Shivam MishraandGitHub 44b1047b90 feat(perf): contacts query performance (#7175)
* feat: use more indexable where condition

* feat: add index concurrently

* chore: update schema

* refactor: update index name
2023-05-23 17:22:47 +05:30
Shivam MishraandGitHub 9c6c19c3e5 fix: CSAT filter metrics rendering & conversation reports not working [CW-1840, CW-1818] (#7170)
* fix: emoji rendering for CSAT

* feat: add tests for CSAT Metrics

* fix: allow rating in metrics

* refactor: hide satisfaction score & total response chart if rating filter is enabled

* refactor: optional chaining in group by

* fix: spacing using autofill

* test: update csat metrics tests

* test: CSAT metric card
2023-05-23 16:47:04 +05:30
Nithin David ThomasandGitHub 2764338453 fix: Fixes wrong copy being sent to users via email invite [cw-1290] (#7159)
* fix: Fixes invite email copy

* fix: Fixes wrong copy being sent to users via email invite
2023-05-23 09:37:40 +05:30
Shivam MishraandGitHub 4f0fcfcbcd feat: allow Microsoft Clarity from CDN (#7163)
* feat: add microsoft clarity to main app

* feat: mask messages

* fix: spacing
2023-05-23 08:00:01 +05:30
Pranav Raj SandGitHub fdbaf98c3b fix: Update breaking specs (#7169) 2023-05-22 17:01:27 -07:00
03bbd048a7 chore: Add action to set dark mode from the react-native-widget (#7167)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-05-22 15:34:09 -07:00
Tejaswini ChileandGitHub d481b9fbcf feat: Limit the number of custom filters per user (#7101) 2023-05-22 18:03:15 +05:30
Vishnu NarayananandGitHub 68dedc37ba chore: update auditlog timestamp format (#7128) 2023-05-22 17:08:50 +05:30
Shivam MishraandGitHub 958c0d7946 feat(perf): improve performance of conversation filter API [CW-1605] (#7120)
* feat(perf): don't use count

* feat(perf): include messages in the conversation query

* feat: include contact inboxes

* refactor: use blank?
2023-05-22 16:03:11 +05:30
Tejaswini ChileandGitHub 9553329092 chore: find instagram_direct_message query (#7157) 2023-05-22 16:01:31 +05:30
Nithin David ThomasandGitHub d01f2063d0 feat: Creates component to show articles search results [CW-1451] (#7126)
* feat: Creates component to show articles search results

* Adds story for the component
2023-05-22 15:15:19 +05:30
Tejaswini ChileandGitHub 590e4e9c1c Fix: prevent IMAPBadResponse exception from sending the authorization mail (#7154) 2023-05-22 13:51:56 +05:30
Tejaswini ChileandGitHub 0e903d2365 chore: Add delay for slack job when message has attachments (#7107) 2023-05-22 13:51:14 +05:30
Shivam MishraandGitHub d6ce1ceeeb fix: CSAT chart showing incorrect data (#7152)
* fix: computation of data for csat chart

* fix: make horizontal chart reactive
2023-05-22 11:01:01 +05:30
Sivin VargheseandGitHub e8f56d0e56 chore: Update greeting message label in settings (#7088) 2023-05-21 21:07:37 -07:00
Pranav Raj SandGitHub b8dae07c0f feat: Add an option to use darkMode only in the widget (#7151) 2023-05-22 09:05:05 +05:30
Sojan JoseandGitHub e3f4be97c0 fix: Reauthorize when channel settings updated (#7134)
Fixes: https://linear.app/chatwoot/issue/CW-1803/bug-inbox-doesnt-update-reauthorisation-required-automattically
2023-05-19 22:04:34 +05:30
Sojan 1f4d096804 Merge branch 'hotfix/2.17.1' into develop 2023-05-19 20:56:16 +05:30
Sivin VargheseandGitHub 1bcc438b21 fix: Problem when using Brazilian Portuguese (#7135)
Fixes:  #7127
2023-05-19 20:55:01 +05:30
Vishnu NarayananandGitHub 8b3d13a920 fix: add libvips to Dockerfile for rails7 (#7133)
Fixes: #7124
Fixes: https://linear.app/chatwoot/issue/CW-1805/investigate-217-upgrade-issues-for-dockerk8s-installations
2023-05-19 17:59:33 +05:30
Sojan dcdd58bae5 Bump version to 2.17.1 2023-05-19 16:00:27 +05:30
Sojan de1fb6ba4e Merge branch 'release/2.17.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-05-16 12:33:47 +05:30
Pranav Raj S 18c5c47836 Merge branch 'hotfix/2.16.1' 2023-05-02 15:53:33 -07:00
119 changed files with 2478 additions and 200 deletions
+1 -1
View File
@@ -157,7 +157,7 @@ jobs:
- run:
name: Code Climate Test Coverage
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json buildreports/lcov.info
~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
- persist_to_workspace:
root: coverage
+3
View File
@@ -173,6 +173,9 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## LogRocket
# LOG_ROCKET_PROJECT_ID=xxxxx/some-project
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
## Scout
## https://scoutapm.com/docs/ruby/configuration
# SCOUT_KEY=YOURKEY
+1 -1
View File
@@ -784,8 +784,8 @@ GEM
PLATFORMS
arm64-darwin-20
arm64-darwin-22
arm64-darwin-21
arm64-darwin-22
ruby
x86_64-darwin-18
x86_64-darwin-20
@@ -63,9 +63,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
end
def conversation
@conversation ||= Conversation.where(
@conversation ||= Conversation.where(conversation_params).find_by(
"additional_attributes ->> 'type' = 'instagram_direct_message'"
).find_by(conversation_params) || build_conversation
) || build_conversation
end
def message_content
@@ -96,6 +96,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: { type: 'instagram_direct_message' }
@@ -1,11 +1,13 @@
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::Contacts::BaseController
include HmacConcern
before_action :ensure_inbox, only: [:create]
def create
@contact_inbox = ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: params[:source_id]
source_id: params[:source_id],
hmac_verified: hmac_verified?
).perform
end
@@ -1,6 +1,7 @@
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
include Events::Types
include DateRangeHelper
include HmacConcern
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :inbox, :contact, :contact_inbox, only: [:create]
@@ -104,9 +105,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def set_conversation_status
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
# status = params[:status] == 'bot' ? 'pending' : params[:status]
@conversation.status = params[:status]
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end
@@ -152,7 +150,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: params[:source_id]
source_id: params[:source_id],
hmac_verified: hmac_verified?
).perform
end
@@ -11,6 +11,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
@custom_filter = current_user.custom_filters.create!(
permitted_payload.merge(account_id: Current.account.id)
)
render json: { error: @custom_filter.errors.messages }, status: :unprocessable_entity and return unless @custom_filter.valid?
end
def update
@@ -44,26 +44,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def update
@inbox.update!(permitted_params.except(:channel))
update_inbox_working_hours
channel_attributes = get_channel_attributes(@inbox.channel_type)
# Inbox update doesn't necessarily need channel attributes
return if permitted_params(channel_attributes)[:channel].blank?
if @inbox.inbox_type == 'Email'
begin
validate_email_channel(channel_attributes)
rescue StandardError => e
render json: { message: e }, status: :unprocessable_entity and return
end
@inbox.channel.reauthorized!
end
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
update_channel_feature_flags
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
update_channel if channel_update_required?
end
def agent_bot
@@ -103,6 +84,35 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end
def update_channel
channel_attributes = get_channel_attributes(@inbox.channel_type)
return if permitted_params(channel_attributes)[:channel].blank?
validate_and_update_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
reauthorize_and_update_channel(channel_attributes)
update_channel_feature_flags
end
def channel_update_required?
permitted_params(get_channel_attributes(@inbox.channel_type))[:channel].present?
end
def validate_and_update_email_channel(channel_attributes)
validate_email_channel(channel_attributes)
rescue StandardError => e
render json: { message: e }, status: :unprocessable_entity and return
end
def reauthorize_and_update_channel(channel_attributes)
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
end
def update_channel_feature_flags
return unless @inbox.web_widget?
return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags
+5
View File
@@ -0,0 +1,5 @@
module HmacConcern
def hmac_verified?
ActiveModel::Type::Boolean.new.cast(params[:hmac_verified]).present?
end
end
@@ -37,3 +37,5 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
@@ -50,6 +50,13 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
# rubocop:enable Rails/I18nLocaleTexts
end
def reset_cache
requested_resource.reset_cache_keys
# rubocop:disable Rails/I18nLocaleTexts
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Cache keys cleared')
# rubocop:enable Rails/I18nLocaleTexts
end
def destroy
account = Account.find(params[:id])
+2 -2
View File
@@ -35,10 +35,10 @@ class CSATReportsAPI extends ApiClient {
});
}
getMetrics({ from, to, user_ids, inbox_id, team_id } = {}) {
getMetrics({ from, to, user_ids, inbox_id, team_id, rating } = {}) {
// no ratings for metrics
return axios.get(`${this.url}/metrics`, {
params: { since: from, until: to, user_ids, inbox_id, team_id },
params: { since: from, until: to, user_ids, inbox_id, team_id, rating },
});
}
}
@@ -115,12 +115,10 @@
</template>
<script>
import format from 'date-fns/format';
import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import { isValidURL } from '../helper/URLHelper';
const DATE_FORMAT = 'yyyy-MM-dd';
export default {
components: {
@@ -144,9 +142,6 @@ export default {
computed: {
formattedValue() {
if (this.isAttributeTypeDate) {
return format(new Date(this.value || new Date()), DATE_FORMAT);
}
if (this.isAttributeTypeCheckbox) {
return this.value === 'false' ? false : this.value;
}
@@ -193,8 +188,11 @@ export default {
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
},
displayValue() {
if (this.attributeType === 'date') {
return format(new Date(this.editedValue), 'dd-MM-yyyy');
if (this.attributeType === 'date' && this.editedValue) {
return this.editedValue
.split('-')
.reverse()
.join('-');
}
return this.editedValue;
},
@@ -247,17 +245,12 @@ export default {
}
},
onUpdate() {
const updatedValue =
this.attributeType === 'date'
? format(new Date(this.editedValue), DATE_FORMAT)
: this.editedValue;
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
this.isEditing = false;
this.$emit('update', this.attributeKey, updatedValue);
this.$emit('update', this.attributeKey, this.editedValue);
},
onDelete() {
this.isEditing = false;
@@ -45,6 +45,14 @@ export default {
default: () => {},
},
},
watch: {
collection() {
this.renderChart(this.collection, {
...chartOptions,
...this.chartOptions,
});
},
},
mounted() {
this.renderChart(this.collection, {
...chartOptions,
@@ -25,10 +25,14 @@
<blockquote v-if="storyReply" class="story-reply-quote">
<span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span>
<bubble-image
v-if="!hasStoryError"
v-if="!hasImgStoryError && storyUrl"
:url="storyUrl"
@error="onStoryLoadError"
/>
<bubble-video
v-else-if="hasImgStoryError && storyUrl"
:url="storyUrl"
/>
</blockquote>
<bubble-text
v-if="data.content"
@@ -200,7 +204,7 @@ export default {
hasImageError: false,
contextMenuPosition: {},
showBackgroundHighlight: false,
hasStoryError: false,
hasImgStoryError: false,
};
},
computed: {
@@ -429,12 +433,12 @@ export default {
watch: {
data() {
this.hasImageError = false;
this.hasStoryError = false;
this.hasImgStoryError = false;
},
},
mounted() {
this.hasImageError = false;
this.hasStoryError = false;
this.hasImgStoryError = false;
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer();
},
@@ -464,7 +468,7 @@ export default {
this.hasImageError = true;
},
onStoryLoadError() {
this.hasStoryError = true;
this.hasImgStoryError = true;
},
openContextMenu(e) {
const shouldSkipContextMenu =
@@ -36,6 +36,7 @@
v-for="message in getReadMessages"
:key="message.id"
class="message--read ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
@@ -56,6 +57,7 @@
v-for="message in getUnReadMessages"
:key="message.id"
class="message--unread ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
@@ -501,7 +501,7 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWhatsAppChannel) {
if (this.isAWhatsAppChannel || this.isAPIInbox) {
return AUDIO_FORMATS.OGG;
}
return AUDIO_FORMATS.WAV;
@@ -79,6 +79,7 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin';
import agentMixin from 'dashboard/mixins/agentMixin';
import { mapGetters } from 'vuex';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
export default {
@@ -87,7 +88,7 @@ export default {
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
},
mixins: [snoozeTimesMixin],
mixins: [snoozeTimesMixin, agentMixin],
props: {
status: {
type: String,
@@ -202,6 +203,16 @@ export default {
teams: 'teams/getTeams',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
filteredAgentOnAvailability() {
const agents = this.$store.getters[
'inboxAssignableAgents/getAssignableAgents'
](this.inboxId);
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
const filteredAgents = this.sortedAgentsByAvailability(
agentsByUpdatedPresence
);
return filteredAgents;
},
assignableAgents() {
return [
{
@@ -212,9 +223,7 @@ export default {
account_id: 0,
email: 'None',
},
...this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.inboxId
),
...this.filteredAgentOnAvailability,
];
},
},
@@ -246,6 +255,7 @@ export default {
...(type === 'icon' && { icon: option.icon }),
...(type === 'label' && { color: option.color }),
...(type === 'agent' && { thumbnail: option.thumbnail }),
...(type === 'agent' && { status: option.availability_status }),
...(type === 'text' && { label: option.label }),
...(type === 'label' && { label: option.title }),
...(type === 'agent' && { label: option.name }),
@@ -15,6 +15,7 @@
v-if="variant === 'agent'"
:username="option.label"
:src="option.thumbnail"
:status="option.status"
size="20px"
class="agent-thumbnail"
/>
@@ -10,15 +10,22 @@
"TITLE": "Manage Audit Logs",
"DESC": "Audit Logs are trails for events and actions in a Chatwoot System.",
"TABLE_HEADER": [
"User",
"Action",
"IP Address",
"Time"
"Activity",
"Time",
"IP Address"
]
},
"API": {
"SUCCESS_MESSAGE": "AuditLogs retrieved successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
},
"ACTION": {
"ADD": "created",
"EDIT": "updated",
"DELETE": "deleted",
"SIGN_IN": "signed in",
"SIGN_OUT": "signed out",
"SYSTEM": "System"
}
}
}
@@ -74,6 +74,14 @@
"DELETE": "Delete article"
}
},
"ARTICLE_SEARCH_RESULT": {
"UNCATEGORIZED": "Uncategorized",
"INSERT_ARTICLE": "Insert",
"NO_RESULT": "No articles found",
"COPY_LINK": "Copy article link to clipboard",
"OPEN_LINK": "Open article in new tab",
"PREVIEW_LINK": "Preview article"
},
"PORTAL": {
"HEADER": "Portals",
"DEFAULT": "Default",
@@ -83,7 +83,7 @@
},
"CHANNEL_GREETING_TOGGLE": {
"LABEL": "Enable channel greeting",
"HELP_TEXT": "Automatically send a greeting message after the contact's first message in a conversation.",
"HELP_TEXT": "Auto-send greeting messages when customers start a conversation and send their first message.",
"ENABLED": "Enabled",
"DISABLED": "Disabled"
},
+46 -17
View File
@@ -2,39 +2,68 @@ import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentAccountId: 'getCurrentAccountId',
}),
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.inboxId
);
},
...mapGetters({ currentUser: 'getCurrentUser' }),
isAgentSelected() {
return this.currentChat?.meta?.assignee;
},
createNoneAgent() {
return {
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
};
},
agentsList() {
const agents = this.assignableAgents || [];
return [
...(this.isAgentSelected
? [
{
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
},
]
: []),
...agents,
].map(item =>
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
const none = this.createNoneAgent;
const filteredAgentsByAvailability = this.sortedAgentsByAvailability(
agentsByUpdatedPresence
);
const filteredAgents = [
...(this.isAgentSelected ? [none] : []),
...filteredAgentsByAvailability,
];
return filteredAgents;
},
},
methods: {
getAgentsByAvailability(agents, availability) {
return agents
.filter(agent => agent.availability_status === availability)
.sort((a, b) => a.name.localeCompare(b.name));
},
sortedAgentsByAvailability(agents) {
const onlineAgents = this.getAgentsByAvailability(agents, 'online');
const busyAgents = this.getAgentsByAvailability(agents, 'busy');
const offlineAgents = this.getAgentsByAvailability(agents, 'offline');
const filteredAgents = [...onlineAgents, ...busyAgents, ...offlineAgents];
return filteredAgents;
},
getAgentsByUpdatedPresence(agents) {
// Here we are updating the availability status of the current user dynamically (live) based on the current account availability status
const agentsWithDynamicPresenceUpdate = agents.map(item =>
item.id === this.currentUser.id
? {
...item,
availability_status: this.currentUser.availability_status,
availability_status: this.currentUser.accounts.find(
account => account.id === this.currentAccountId
).availability_status,
}
: item
);
return agentsWithDynamicPresenceUpdate;
},
},
};
@@ -20,6 +20,36 @@ export default {
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
],
formattedAgents: [
{
@@ -32,7 +62,17 @@ export default {
},
{
account_id: 1,
availability_status: 'busy',
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
@@ -40,6 +80,70 @@ export default {
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
],
onlineAgents: [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
],
busyAgents: [
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
@@ -51,4 +155,92 @@ export default {
role: 'agent',
},
],
offlineAgents: [
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
],
sortedByAvailability: [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
],
formattedAgentsByPresenceOnline: [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abr@chatwoot.com',
id: 1,
name: 'Abraham Keta',
role: 'agent',
},
],
formattedAgentsByPresenceOffline: [
{
account_id: 1,
availability_status: 'offline',
available_name: 'Abraham',
confirmed: true,
email: 'abr@chatwoot.com',
id: 1,
name: 'Abraham Keta',
role: 'agent',
},
],
};
@@ -12,12 +12,71 @@ describe('agentMixin', () => {
getters = {
getCurrentUser: () => ({
id: 1,
availability_status: 'busy',
accounts: [
{
id: 1,
availability_status: 'online',
auto_offline: false,
},
],
}),
getCurrentAccountId: () => 1,
};
store = new Vuex.Store({ getters });
});
it('return agents by availability', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [agentMixin],
data() {
return {
inboxId: 1,
currentChat: { meta: { assignee: { name: 'John' } } },
};
},
computed: {
assignableAgents() {
return agentFixtures.allAgents;
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'online')
).toEqual(agentFixtures.onlineAgents);
expect(
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'busy')
).toEqual(agentFixtures.busyAgents);
expect(
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'offline')
).toEqual(agentFixtures.offlineAgents);
});
it('return sorted agents by availability', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [agentMixin],
data() {
return {
inboxId: 1,
currentChat: { meta: { assignee: { name: 'John' } } },
};
},
computed: {
assignableAgents() {
return agentFixtures.allAgents;
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(
wrapper.vm.sortedAgentsByAvailability(agentFixtures.allAgents)
).toEqual(agentFixtures.sortedByAvailability);
});
it('return formatted agents', () => {
const Component = {
render() {},
@@ -38,4 +97,44 @@ describe('agentMixin', () => {
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.agentsList).toEqual(agentFixtures.formattedAgents);
});
it('return formatted agents by presence', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [agentMixin],
data() {
return {
inboxId: 1,
currentChat: { meta: { assignee: { name: 'John' } } },
};
},
computed: {
currentUser() {
return {
id: 1,
accounts: [
{
id: 1,
availability_status: 'offline',
auto_offline: false,
},
],
};
},
currentAccountId() {
return 1;
},
assignableAgents() {
return agentFixtures.allAgents;
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(
wrapper.vm.getAgentsByUpdatedPresence(
agentFixtures.formattedAgentsByPresenceOnline
)
).toEqual(agentFixtures.formattedAgentsByPresenceOffline);
});
});
@@ -98,7 +98,7 @@ export default {
const errorMessage = error?.message;
this.alertMessage =
errorMessage || this.filterType === 0
? this.$t('FILTER.CUSTOM_VIEWS.ADD.API_FOLDERS.ERROR_MESSAGE')
? errorMessage
: this.$t('FILTER.CUSTOM_VIEWS.ADD.API_SEGMENTS.ERROR_MESSAGE');
} finally {
this.showAlert(this.alertMessage);
@@ -0,0 +1,130 @@
<template>
<div class="article-item">
<h4 class="text-block-title margin-bottom-0">{{ title }}</h4>
<p class="margin-bottom-0 text-truncate">{{ body }}</p>
<div class="footer">
<p class="text-small meta">
{{ locale }}
{{ ` / ` }}
{{
category ||
$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.ARTICLE_SEARCH_RESULT')
}}
</p>
<div class="action-buttons">
<woot-button
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.COPY_LINK')"
variant="hollow"
color-scheme="secondary"
size="tiny"
icon="copy"
@click="handleCopy"
/>
<a
:href="url"
class="button hollow button--only-icon tiny secondary"
rel="noopener noreferrer nofollow"
target="_blank"
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.OPEN_LINK')"
>
<fluent-icon size="12" icon="arrow-up-right" />
<span class="show-for-sr">{{ url }}</span>
</a>
<woot-button
variant="hollow"
color-scheme="secondary"
size="tiny"
icon="preview-link"
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.PREVIEW_LINK')"
@click="handlePreview"
/>
<woot-button
class="insert-button"
variant="smooth"
color-scheme="secondary"
size="tiny"
@click="handleClick"
>
{{ $t('HELP_CENTER.ARTICLE_SEARCH_RESULT.INSERT_ARTICLE') }}
</woot-button>
</div>
</div>
</div>
</template>
<script>
import { copyTextToClipboard } from 'shared/helpers/clipboard';
export default {
name: 'ArticleSearchResultItem',
props: {
title: {
type: String,
default: 'Untitled',
},
body: {
type: String,
default: '',
},
url: {
type: String,
default: '',
},
category: {
type: String,
default: '',
},
locale: {
type: String,
default: 'en-US',
},
},
methods: {
handleClick() {
this.$emit('click');
},
handlePreview() {
this.$emit('preview');
},
async handleCopy() {
await copyTextToClipboard(this.url);
this.showAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
},
},
};
</script>
<style lang="scss" scoped>
.article-item {
display: flex;
flex-direction: column;
gap: var(--space-micro);
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-micro);
}
.meta {
color: var(--s-600);
margin-bottom: 0;
}
.action-buttons {
display: flex;
gap: var(--space-micro);
}
.action-buttons .button:not(.insert-button) {
visibility: hidden;
opacity: 0;
transition: all 0.1s ease-in;
}
.article-item:hover .action-buttons .button:not(.insert-button) {
visibility: visible;
opacity: 1;
}
</style>
@@ -0,0 +1,56 @@
import ArticleSearchResultItem from '../ArticleSearchResultItem.vue';
export default {
title: 'Components/Help Center/ArticleSearchResultItem',
component: ArticleSearchResultItem,
argTypes: {
title: {
defaultValue: 'Setup your account',
control: {
type: 'text',
},
},
body: {
defaultValue:
'You can integrate your Chatwoot account with multiple conversation channels like website live-chat, email, Facebook page, Twitter handle, WhatsApp, etc. You can view all of your conversations from different channels on one dashboard. This helps in reducing the time and friction involved with switching between multiple tools.',
control: {
type: 'text',
},
},
category: {
defaultValue: 'Getting started',
control: {
type: 'text',
},
},
locale: {
defaultValue: 'en-US',
control: {
type: 'text',
},
},
url: {
defaultValue: '/app/accounts/1/conversations/23842',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleSearchResultItem },
template:
'<ArticleSearchResultItem v-bind="$props" ></ArticleSearchResultItem>',
});
export const ArticleSearchResultItemStory = Template.bind({});
ArticleSearchResultItemStory.args = {
title: 'Setup your account',
body: `You can integrate your Chatwoot account with multiple conversation channels like website live-chat, email, Facebook page, Twitter handle, WhatsApp, etc. You can view all of your conversations from different channels on one dashboard. This helps in reducing the time and friction involved with switching between multiple tools.
You can manage your conversations and collaborate with your team on the go with Chatwoot mobile apps (available for Android and iOS).
In this user guide, weve explained the features, capabilities, modes of operation, and step-by-step procedures for easily using the Chatwoot platform.`,
};
@@ -1,8 +1,8 @@
<template>
<div class="column content-box">
<div class="column content-box audit-log--settings">
<!-- List Audit Logs -->
<div class="row">
<div class="small-8 columns with-right-space ">
<div>
<div>
<p
v-if="!uiFlags.fetchingList && !records.length"
class="no-items-error-message"
@@ -16,8 +16,13 @@
<table
v-if="!uiFlags.fetchingList && records.length"
class="woot-table"
class="woot-table width-100"
>
<colgroup>
<col class="column-activity" />
<col />
<col />
</colgroup>
<thead>
<!-- Header -->
<th
@@ -29,16 +34,20 @@
</thead>
<tbody>
<tr v-for="auditLogItem in records" :key="auditLogItem.id">
<td class="wrap-break-words">{{ auditLogItem.username }}</td>
<td class="wrap-break-words">
{{ auditLogItem.auditable_type }}.{{ auditLogItem.action }}
{{ generateLogText(auditLogItem) }}
</td>
<td class="wrap-break-words">
{{
messageTimestamp(
auditLogItem.created_at,
'MMM dd, yyyy hh:mm a'
)
}}
</td>
<td class="remote-address">
{{ auditLogItem.remote_address }}
</td>
<td class="wrap-break-words">
{{ dynamicTime(auditLogItem.created_at) }}
</td>
</tr>
</tbody>
</table>
@@ -76,13 +85,48 @@ export default {
records: 'auditlogs/getAuditLogs',
uiFlags: 'auditlogs/getUIFlags',
meta: 'auditlogs/getMeta',
agentList: 'agents/getAgents',
}),
},
mounted() {
// Fetch API Call
this.$store.dispatch('auditlogs/fetch', { page: 1 });
this.$store.dispatch('agents/get');
},
methods: {
getAgentName(email) {
if (email === null) {
return this.$t('AUDIT_LOGS.ACTION.SYSTEM');
}
const agentName = this.agentList.find(agent => agent.email === email)
?.name;
// If agent does not exist(removed/deleted), return email from audit log
return agentName || email;
},
generateLogText(auditLogItem) {
const username = this.getAgentName(auditLogItem.username);
const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase();
const logActions = {
create: this.$t('AUDIT_LOGS.ACTION.ADD'),
destroy: this.$t('AUDIT_LOGS.ACTION.DELETE'),
update: this.$t('AUDIT_LOGS.ACTION.EDIT'),
sign_in: this.$t('AUDIT_LOGS.ACTION.SIGN_IN'),
sign_out: this.$t('AUDIT_LOGS.ACTION.SIGN_OUT'),
};
// detect if the action is custom user action, which involves
// only the user, such as signing in, signing out etc.
// if it is, then do not show the auditable type
const userActions = this.getUserActions(action);
return `${username} ${logActions[action] || action} ${
userActions ? '' : auditableType
}`;
},
getUserActions(action) {
return ['sign_in', 'sign_out'].includes(action);
},
onPageChange(page) {
window.history.pushState({}, null, `${this.$route.path}?page=${page}`);
try {
@@ -96,12 +140,24 @@ export default {
},
};
</script>
<style scoped>
.remote-address {
width: 14rem;
}
.wrap-break-words {
word-break: break-all;
white-space: normal;
<style lang="scss" scoped>
.audit-log--settings {
display: flex;
justify-content: space-between;
flex-direction: column;
.remote-address {
width: 14rem;
}
.wrap-break-words {
word-break: break-all;
white-space: normal;
}
.column-activity {
width: 60%;
}
}
</style>
@@ -16,7 +16,7 @@
>
{{ $t('CSAT_REPORTS.DOWNLOAD') }}
</woot-button>
<csat-metrics />
<csat-metrics :filters="requestPayload" />
<csat-table :page-index="pageIndex" @page-change="onPageNumberChange" />
</div>
</template>
@@ -92,7 +92,7 @@ export default {
}
if (!this.accountReport.data.length) return {};
const labels = this.accountReport.data.map(element => {
if (this.groupBy.period === GROUP_BY_FILTER[2].period) {
if (this.groupBy?.period === GROUP_BY_FILTER[2].period) {
let week_date = new Date(fromUnixTime(element.timestamp));
const first_day = week_date.getDate() - week_date.getDay();
const last_day = first_day + 6;
@@ -105,10 +105,10 @@ export default {
'dd/MM/yy'
)}`;
}
if (this.groupBy.period === GROUP_BY_FILTER[3].period) {
if (this.groupBy?.period === GROUP_BY_FILTER[3].period) {
return format(fromUnixTime(element.timestamp), 'MMM-yyyy');
}
if (this.groupBy.period === GROUP_BY_FILTER[4].period) {
if (this.groupBy?.period === GROUP_BY_FILTER[4].period) {
return format(fromUnixTime(element.timestamp), 'yyyy');
}
return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy');
@@ -213,7 +213,7 @@ export default {
return {
from,
to,
groupBy: groupBy.period,
groupBy: groupBy?.period,
businessHours,
};
},
@@ -1,5 +1,10 @@
<template>
<div class="medium-2 small-6 csat--metric-card">
<div
class="medium-2 small-6 csat--metric-card"
:class="{
disabled: disabled,
}"
>
<h3 class="heading">
<span>{{ label }}</span>
<fluent-icon
@@ -29,6 +34,10 @@ export default {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
};
</script>
@@ -37,6 +46,13 @@ export default {
margin: 0;
padding: var(--space-normal);
&.disabled {
// grayscale everything
filter: grayscale(100%);
opacity: 0.3;
pointer-events: none;
}
.heading {
align-items: center;
color: var(--color-heading);
@@ -6,16 +6,20 @@
:value="responseCount"
/>
<csat-metric-card
:disabled="ratingFilterEnabled"
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
:value="formatToPercent(satisfactionScore)"
:value="ratingFilterEnabled ? '--' : formatToPercent(satisfactionScore)"
/>
<csat-metric-card
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
:value="formatToPercent(responseRate)"
/>
<div v-if="metrics.totalResponseCount" class="medium-6 report-card">
<div
v-if="metrics.totalResponseCount && !ratingFilterEnabled"
class="medium-6 report-card"
>
<h3 class="heading">
<div class="emoji--distribution">
<div
@@ -24,7 +28,7 @@
class="emoji--distribution-item"
>
<span class="emoji--distribution-key">{{
csatRatings[key - 1].emoji
ratingToEmoji(key)
}}</span>
<span>{{ formatToPercent(rating) }}</span>
</div>
@@ -45,6 +49,12 @@ export default {
components: {
CsatMetricCard,
},
props: {
filters: {
type: Object,
required: true,
},
},
data() {
return {
csatRatings: CSAT_RATINGS,
@@ -57,12 +67,15 @@ export default {
satisfactionScore: 'csat/getSatisfactionScore',
responseRate: 'csat/getResponseRate',
}),
ratingFilterEnabled() {
return Boolean(this.filters.rating);
},
chartData() {
return {
labels: ['Rating'],
datasets: CSAT_RATINGS.map((rating, index) => ({
datasets: CSAT_RATINGS.map(rating => ({
label: rating.emoji,
data: [this.ratingPercentage[index + 1]],
data: [this.ratingPercentage[rating.value]],
backgroundColor: rating.color,
})),
};
@@ -77,6 +90,9 @@ export default {
formatToPercent(value) {
return value ? `${value}%` : '--';
},
ratingToEmoji(value) {
return CSAT_RATINGS.find(rating => rating.value === Number(value)).emoji;
},
},
};
</script>
@@ -231,7 +231,7 @@ export default {
<style scoped>
.filter-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-gap: var(--space-slab);
margin-bottom: var(--space-normal);
@@ -0,0 +1,64 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import CsatMetrics from '../CsatMetrics.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
const mountParams = {
mocks: {
$t: msg => msg,
},
stubs: ['csat-metric-card', 'woot-horizontal-bar'],
};
describe('CsatMetrics.vue', () => {
let getters;
let store;
let wrapper;
const filters = { rating: 3 };
beforeEach(() => {
getters = {
'csat/getMetrics': () => ({ totalResponseCount: 100 }),
'csat/getRatingPercentage': () => ({ 1: 10, 2: 20, 3: 30, 4: 30, 5: 10 }),
'csat/getSatisfactionScore': () => 85,
'csat/getResponseRate': () => 90,
};
store = new Vuex.Store({
getters,
});
wrapper = shallowMount(CsatMetrics, {
store,
localVue,
propsData: { filters },
...mountParams,
});
});
it('computes response count correctly', () => {
expect(wrapper.vm.responseCount).toBe('100');
expect(wrapper.html()).toMatchSnapshot();
});
it('formats values to percent correctly', () => {
expect(wrapper.vm.formatToPercent(85)).toBe('85%');
expect(wrapper.vm.formatToPercent(null)).toBe('--');
});
it('maps rating value to emoji correctly', () => {
const rating = wrapper.vm.csatRatings[0]; // assuming this is { value: 1, emoji: '😡' }
expect(wrapper.vm.ratingToEmoji(rating.value)).toBe(rating.emoji);
});
it('hides report card if rating filter is enabled', () => {
expect(wrapper.find('.report-card').exists()).toBe(false);
});
it('shows report card if rating filter is not enabled', async () => {
await wrapper.setProps({ filters: {} });
expect(wrapper.find('.report-card').exists()).toBe(true);
});
});
@@ -0,0 +1,46 @@
import { createLocalVue, shallowMount } from '@vue/test-utils';
import CsatMetricCard from '../CsatMetricCard.vue';
import VTooltip from 'v-tooltip';
const localVue = createLocalVue();
localVue.use(VTooltip);
describe('CsatMetricCard.vue', () => {
it('renders props correctly', () => {
const label = 'Total Responses';
const value = '100';
const infoText = 'Total number of responses';
const wrapper = shallowMount(CsatMetricCard, {
propsData: { label, value, infoText },
localVue,
stubs: ['fluent-icon'],
});
expect(wrapper.find('.heading span').text()).toMatch(label);
expect(wrapper.find('.metric').text()).toMatch(value);
expect(wrapper.find('.csat--icon').classes()).toContain('has-tooltip');
});
it('adds disabled class when disabled prop is true', () => {
const wrapper = shallowMount(CsatMetricCard, {
propsData: { label: '', value: '', infoText: '', disabled: true },
localVue,
stubs: ['fluent-icon'],
});
expect(wrapper.find('.csat--metric-card').classes()).toContain('disabled');
});
it('does not add disabled class when disabled prop is false', () => {
const wrapper = shallowMount(CsatMetricCard, {
propsData: { label: '', value: '', infoText: '', disabled: false },
localVue,
stubs: ['fluent-icon'],
});
expect(wrapper.find('.csat--metric-card').classes()).not.toContain(
'disabled'
);
});
});
@@ -0,0 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CsatMetrics.vue computes response count correctly 1`] = `
<div class="row csat--metrics-container">
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL" value="100" infotext="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL" value="--" infotext="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP" disabled="true"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL" value="90%" infotext="CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP"></csat-metric-card-stub>
<!---->
</div>
`;
+6
View File
@@ -146,6 +146,12 @@ const runSDK = ({ baseUrl, websiteToken }) => {
IFrameHelper.sendMessage('set-locale', { locale: localeToBeUsed });
},
setColorScheme(darkMode = 'light') {
IFrameHelper.sendMessage('set-color-scheme', {
darkMode: getDarkMode(darkMode),
});
},
reset() {
if (window.$chatwoot.isOpen) {
IFrameHelper.events.toggleBubble();
+1 -1
View File
@@ -1,3 +1,3 @@
export const BUBBLE_DESIGN = ['standard', 'expanded_bubble'];
export const WIDGET_DESIGN = ['standard', 'flat'];
export const DARK_MODE = ['light', 'auto'];
export const DARK_MODE = ['light', 'auto', 'dark'];
@@ -196,6 +196,10 @@
"M5 18C5 17.4477 5.44772 17 6 17H10C10.5523 17 11 17.4477 11 18V21C11 21.5523 10.5523 22 10 22H6C5.44772 22 5 21.5523 5 21V18Z",
"M13 18C13 17.4477 13.4477 17 14 17H18C18.5523 17 19 17.4477 19 18V21C19 21.5523 18.5523 22 18 22H14C13.4477 22 13 21.5523 13 21V18Z"
],
"preview-link-outline": [
"M4.524 6.25a.75.75 0 0 1 .75-.75H18.73a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-.75.75H5.274a.75.75 0 0 1-.75-.75v-3.5Zm1.5.75v2H17.98V7H6.024ZM14.23 11.979a.75.75 0 0 0-.75.75v4.5c0 .414.335.75.75.75h4.5a.75.75 0 0 0 .75-.75v-4.5a.75.75 0 0 0-.75-.75h-4.5Zm.75 4.5v-3h3v3h-3ZM4.524 13.25a.75.75 0 0 1 .75-.75h5.976a.75.75 0 0 1 0 1.5H5.274a.75.75 0 0 1-.75-.75ZM5.274 16a.75.75 0 0 0 0 1.5h5.976a.75.75 0 0 0 0-1.5H5.274Z",
"M2 5.75A2.75 2.75 0 0 1 4.75 3h14.5A2.75 2.75 0 0 1 22 5.75v12.5A2.75 2.75 0 0 1 19.25 21H4.75A2.75 2.75 0 0 1 2 18.25V5.75ZM4.75 4.5c-.69 0-1.25.56-1.25 1.25v12.5c0 .69.56 1.25 1.25 1.25h14.5c.69 0 1.25-.56 1.25-1.25V5.75c0-.69-.56-1.25-1.25-1.25H4.75Z"
],
"priority-urgent-outline": [
"M2.33325 2.91667C2.33325 2.27233 2.85559 1.75 3.49992 1.75C4.14425 1.75 4.66659 2.27233 4.66659 2.91667V8.16667C4.66659 8.811 4.14425 9.33333 3.49992 9.33333C2.85559 9.33333 2.33325 8.811 2.33325 8.16667V2.91667Z",
"M2.33325 11.0833C2.33325 10.439 2.85559 9.91667 3.49992 9.91667C4.14425 9.91667 4.66659 10.439 4.66659 11.0833C4.66659 11.7277 4.14425 12.25 3.49992 12.25C2.85559 12.25 2.33325 11.7277 2.33325 11.0833Z",
+2 -1
View File
@@ -39,7 +39,8 @@ export const getUnixStartOfDay = date => getUnixTime(startOfDay(date));
export const getUnixEndOfDay = date => getUnixTime(endOfDay(date));
export const generateRelativeTime = (value, unit, languageCode) => {
const rtf = new Intl.RelativeTimeFormat(languageCode, {
const code = languageCode?.replace(/_/g, '-'); // Hacky fix we need to handle it from source
const rtf = new Intl.RelativeTimeFormat(code, {
numeric: 'auto',
});
return rtf.format(value, unit);
@@ -64,15 +64,59 @@ describe('#isTimeAfter', () => {
});
});
describe('#generateRelativeTime', () => {
it('should return correct relative time', () => {
expect(generateRelativeTime(-1, 'day', 'en')).toEqual('yesterday');
expect(generateRelativeTime(1, 'day', 'en')).toEqual('tomorrow');
expect(generateRelativeTime(1, 'hour', 'en')).toEqual('in 1 hour');
expect(generateRelativeTime(-1, 'hour', 'en')).toEqual('1 hour ago');
expect(generateRelativeTime(1, 'minute', 'en')).toEqual('in 1 minute');
expect(generateRelativeTime(-1, 'minute', 'en')).toEqual('1 minute ago');
expect(generateRelativeTime(1, 'second', 'en')).toEqual('in 1 second');
expect(generateRelativeTime(-1, 'second', 'en')).toEqual('1 second ago');
describe('generateRelativeTime', () => {
it('should return a string with the relative time', () => {
const value = 1;
const unit = 'second';
const languageCode = 'en-US';
const expectedResult = 'in 1 second';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
it('should return a string with the relative time in a different language', () => {
const value = 10;
const unit = 'minute';
const languageCode = 'de-DE';
const expectedResult = 'in 10 Minuten';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
it('should return a string with the relative time for a different unit', () => {
const value = 1;
const unit = 'hour';
const languageCode = 'en-US';
const expectedResult = 'in 1 hour';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
it('should throw an error if the value is not a number', () => {
const value = 1;
const unit = 'day';
const languageCode = 'en_US';
const expectedResult = 'tomorrow';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
it('should throw an error if the value is not a number', () => {
const value = 1;
const unit = 'day';
const languageCode = 'en-US';
const expectedResult = 'tomorrow';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
});
+12
View File
@@ -2,6 +2,7 @@
<div
v-if="!conversationSize && isFetchingList"
class="flex flex-1 items-center h-full bg-black-25 justify-center"
:class="{ dark: prefersDarkMode }"
>
<spinner size="" />
</div>
@@ -13,6 +14,7 @@
'is-widget-right': isRightAligned,
'is-bubble-hidden': hideMessageBubble,
'is-flat-design': isWidgetStyleFlat,
dark: prefersDarkMode,
}"
>
<router-view />
@@ -65,6 +67,7 @@ export default {
isFetchingList: 'conversation/getIsFetchingList',
isRightAligned: 'appConfig/isRightAligned',
isWidgetOpen: 'appConfig/getIsWidgetOpen',
darkMode: 'appConfig/darkMode',
messageCount: 'conversation/getMessageCount',
unreadMessageCount: 'conversation/getUnreadMessageCount',
isWidgetStyleFlat: 'appConfig/isWidgetStyleFlat',
@@ -75,6 +78,12 @@ export default {
isRNWebView() {
return RNHelper.isRNWebView();
},
prefersDarkMode() {
const isOSOnDarkMode =
this.darkMode === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
return isOSOnDarkMode || this.darkMode === 'dark';
},
},
watch: {
activeCampaign() {
@@ -108,6 +117,7 @@ export default {
'setReferrerHost',
'setWidgetColor',
'setBubbleVisibility',
'setColorScheme',
]),
...mapActions('conversation', ['fetchOldConversations', 'setUserLastSeen']),
...mapActions('campaign', [
@@ -298,6 +308,8 @@ export default {
} else if (message.event === 'set-locale') {
this.setLocale(message.locale);
this.setBubbleLabel();
} else if (message.event === 'set-color-scheme') {
this.setColorScheme(message.darkMode);
} else if (message.event === 'toggle-open') {
this.$store.dispatch('appConfig/toggleWidgetOpen', message.isOpen);
@@ -42,11 +42,10 @@
</div>
<p
v-if="message.showAvatar || hasRecordedResponse"
v-dompurify-html="agentName"
class="agent-name"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
>
{{ agentName }}
</p>
/>
</div>
</div>
@@ -60,7 +60,7 @@ export default {
isAgentTyping: 'conversation/getIsAgentTyping',
}),
colorSchemeClass() {
return `${this.darkMode === 'light' ? 'light' : 'dark'}`;
return `${this.darkMode === 'dark' ? 'dark-scheme' : 'light-scheme'}`;
},
},
watch: {
@@ -117,10 +117,10 @@ export default {
overflow-y: auto;
color-scheme: light dark;
&.light {
&.light-scheme {
color-scheme: light;
}
&.dark {
&.dark-scheme {
color-scheme: dark;
}
}
@@ -9,6 +9,9 @@ export default {
if (this.darkMode === 'light') {
return light;
}
if (this.darkMode === 'dark') {
return dark;
}
return light + ' ' + dark;
},
},
@@ -1,5 +1,6 @@
import {
SET_BUBBLE_VISIBILITY,
SET_COLOR_SCHEME,
SET_REFERRER_HOST,
SET_WIDGET_APP_CONFIG,
SET_WIDGET_COLOR,
@@ -55,6 +56,9 @@ export const actions = {
setWidgetColor({ commit }, widgetColor) {
commit(SET_WIDGET_COLOR, widgetColor);
},
setColorScheme({ commit }, darkMode) {
commit(SET_COLOR_SCHEME, darkMode);
},
setReferrerHost({ commit }, referrerHost) {
commit(SET_REFERRER_HOST, referrerHost);
},
@@ -83,6 +87,9 @@ export const mutations = {
[SET_BUBBLE_VISIBILITY]($state, hideMessageBubble) {
$state.hideMessageBubble = hideMessageBubble;
},
[SET_COLOR_SCHEME]($state, darkMode) {
$state.darkMode = darkMode;
},
};
export default {
@@ -24,4 +24,11 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([['SET_WIDGET_COLOR', '#eaeaea']]);
});
});
describe('#setColorScheme', () => {
it('creates actions for dark mode properly', () => {
actions.setColorScheme({ commit }, 'dark');
expect(commit.mock.calls).toEqual([['SET_COLOR_SCHEME', 'dark']]);
});
});
});
@@ -24,4 +24,12 @@ describe('#mutations', () => {
expect(state.widgetColor).toEqual('#00bcd4');
});
});
describe('#SET_COLOR_SCHEME', () => {
it('sets dark mode properly', () => {
const state = { darkMode: 'light' };
mutations.SET_COLOR_SCHEME(state, 'dark');
expect(state.darkMode).toEqual('dark');
});
});
});
+1
View File
@@ -2,6 +2,7 @@ export const CLEAR_CONVERSATION_ATTRIBUTES = 'CLEAR_CONVERSATION_ATTRIBUTES';
export const SET_CONVERSATION_ATTRIBUTES = 'SET_CONVERSATION_ATTRIBUTES';
export const SET_WIDGET_APP_CONFIG = 'SET_WIDGET_APP_CONFIG';
export const SET_WIDGET_COLOR = 'SET_WIDGET_COLOR';
export const SET_COLOR_SCHEME = 'SET_COLOR_SCHEME';
export const UPDATE_CONVERSATION_ATTRIBUTES = 'UPDATE_CONVERSATION_ATTRIBUTES';
export const TOGGLE_WIDGET_OPEN = 'TOGGLE_WIDGET_OPEN';
export const SET_REFERRER_HOST = 'SET_REFERRER_HOST';
@@ -3,10 +3,8 @@ class Channels::Whatsapp::TemplatesSyncSchedulerJob < ApplicationJob
def perform
Channel::Whatsapp.where('message_templates_last_updated <= ? OR message_templates_last_updated IS NULL',
15.minutes.ago).find_in_batches do |channels_batch|
channels_batch.each do |channel|
Channels::Whatsapp::TemplatesSyncJob.perform_later(channel)
end
3.hours.ago).limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT).all.each do |channel|
Channels::Whatsapp::TemplatesSyncJob.perform_later(channel)
end
end
end
+5 -2
View File
@@ -20,8 +20,11 @@ class HookJob < ApplicationJob
return unless ['message.created'].include?(event_name)
message = event_data[:message]
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
if message.attachments.blank?
::SendOnSlackJob.perform_later(message, hook)
else
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
end
end
def process_dialogflow_integration(hook, event_name, event_data)
+3 -2
View File
@@ -7,9 +7,10 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
return unless should_fetch_email?(channel)
process_email_for_channel(channel)
rescue *ExceptionList::IMAP_EXCEPTIONS
rescue *ExceptionList::IMAP_EXCEPTIONS => e
Rails.logger.error e
channel.authorization_error!
rescue EOFError, OpenSSL::SSL::SSLError => e
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError => e
Rails.logger.error e
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
+7
View File
@@ -0,0 +1,7 @@
class SendOnSlackJob < ApplicationJob
queue_as :medium
def perform(message, hook)
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
end
end
+1 -1
View File
@@ -77,4 +77,4 @@ class AutomationRule < ApplicationRecord
end
end
AutomationRule.include_mod_with('Audit::Inbox')
AutomationRule.include_mod_with('Audit::AutomationRule')
+21 -9
View File
@@ -4,29 +4,41 @@ module CacheKeys
include CacheKeysHelper
include Events::Types
included do
class_attribute :cacheable_models
self.cacheable_models = [Label, Inbox, Team]
end
def cache_keys
{
label: fetch_value_for_key(id, Label.name.underscore),
inbox: fetch_value_for_key(id, Inbox.name.underscore),
team: fetch_value_for_key(id, Team.name.underscore)
}
keys = {}
self.class.cacheable_models.each do |model|
keys[model.name.underscore.to_sym] = fetch_value_for_key(id, model.name.underscore)
end
keys
end
def invalidate_cache_key_for(key)
prefixed_cache_key = get_prefixed_cache_key(id, key)
Redis::Alfred.del(prefixed_cache_key)
dispatch_cache_udpate_event
Redis::Alfred.delete(prefixed_cache_key)
dispatch_cache_update_event
end
def update_cache_key(key)
prefixed_cache_key = get_prefixed_cache_key(id, key)
Redis::Alfred.set(prefixed_cache_key, Time.now.utc.to_i)
dispatch_cache_udpate_event
dispatch_cache_update_event
end
def reset_cache_keys
self.class.cacheable_models.each do |model|
invalidate_cache_key_for(model.name.underscore)
end
end
private
def dispatch_cache_udpate_event
def dispatch_cache_update_event
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, cache_keys: cache_keys, account: self)
end
end
+10
View File
@@ -6,6 +6,16 @@ module Pubsubable
included do
# Used by the actionCable/PubSub Service we use for real time communications
has_secure_token :pubsub_token
before_save :rotate_pubsub_token
end
def rotate_pubsub_token
# ATM we are only rotating the token if the user is changing their password
return unless is_a?(User)
# Using the class method to avoid the extra Save
# TODO: Should we do this on signin ?
self.pubsub_token = self.class.generate_unique_secure_token if will_save_change_to_encrypted_password?
end
def pubsub_token
+2 -1
View File
@@ -19,6 +19,7 @@
# index_contacts_on_account_id (account_id)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
# index_contacts_on_identifiable_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text)) # rubocop:disable Layout/LineLength
# index_contacts_on_phone_number_and_account_id (phone_number,account_id)
# uniq_email_per_account_contact (email,account_id) UNIQUE
# uniq_identifier_per_account_contact (identifier,account_id) UNIQUE
@@ -136,7 +137,7 @@ class Contact < ApplicationRecord
end
def self.resolved_contacts
where("COALESCE(NULLIF(contacts.email,''),NULLIF(contacts.phone_number,''),NULLIF(contacts.identifier,'')) IS NOT NULL")
where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
end
def discard_invalid_attrs
+8
View File
@@ -17,8 +17,16 @@
# index_custom_filters_on_user_id (user_id)
#
class CustomFilter < ApplicationRecord
MAX_FILTER_PER_USER = 50
belongs_to :user
belongs_to :account
enum filter_type: { conversation: 0, contact: 1, report: 2 }
validate :validate_number_of_filters
def validate_number_of_filters
return true if account.custom_filters.where(user_id: user_id).size < MAX_FILTER_PER_USER
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
end
end
+1 -1
View File
@@ -101,7 +101,7 @@ class Notification < ApplicationRecord
I18n.t(
'notifications.notification_title.assigned_conversation_new_message',
display_id: conversation.display_id,
content: primary_actor&.content&.truncate_words(10)
content: transform_user_mention_content(primary_actor&.content&.truncate_words(10))
)
when 'conversation_mention'
"[##{conversation&.display_id}] #{transform_user_mention_content primary_actor&.content}"
+2
View File
@@ -166,3 +166,5 @@ class User < ApplicationRecord
macros.personal.destroy_all
end
end
User.include_mod_with('Audit::User')
+1 -1
View File
@@ -38,4 +38,4 @@ class Webhook < ApplicationRecord
end
end
Webhook.include_mod_with('Audit::Inbox')
Webhook.include_mod_with('Audit::Webhook')
+2 -1
View File
@@ -58,8 +58,9 @@ class Conversations::FilterService < FilterService
def conversations
@conversations = @conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
)
@conversations.latest.page(current_page)
end
end
@@ -23,7 +23,8 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
def sync_templates
response = HTTParty.get("#{api_base_path}/configs/templates", headers: api_headers)
whatsapp_channel.update(message_templates: response['waba_templates'], message_templates_last_updated: Time.now.utc) if response.success?
whatsapp_channel[:message_templates] = response['waba_templates'] if response.success?
whatsapp_channel.update(message_templates_last_updated: Time.now.utc)
end
def validate_provider_config?
@@ -24,8 +24,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
def sync_templates
templates = fetch_whatsapp_templates("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
whatsapp_channel.update(message_templates: templates, message_templates_last_updated: Time.now.utc) if templates.present?
whatsapp_channel[:message_templates] = templates if templates.present?
whatsapp_channel.update(message_templates_last_updated: Time.now.utc)
end
def fetch_whatsapp_templates(url)
@@ -17,7 +17,7 @@ json.meta do
end
json.id conversation.display_id
if conversation.messages.count.zero?
if conversation.messages.first.blank?
json.messages []
elsif conversation.unread_incoming_messages.count.zero?
json.messages [conversation.messages.includes([{ attachments: [{ file_attachment: [:blob] }] }]).last.try(:push_event_data)]
@@ -9,9 +9,11 @@
<% if @resource.confirmed? %>
<p>You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:</p>
<% else %>
<% if account_user&.inviter.blank? %>
<p>
Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you.
</p>
<% end %>
<p>Please take a moment and click the link below and activate your account.</p>
<% end %>
@@ -24,4 +26,4 @@
<p><%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %></p>
<% else %>
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
<% end %>
<% end %>
+11 -2
View File
@@ -50,8 +50,8 @@
window.browserConfig = {
browser_name: '<%= browser.name %>',
}
window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_DSN', '')%>'
window.logRocketProjectId = '<%= ENV.fetch('LOG_ROCKET_PROJECT_ID', '')%>'
window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_DSN', '') %>'
window.logRocketProjectId = '<%= ENV.fetch('LOG_ROCKET_PROJECT_ID', '') %>'
</script>
<% if @global_config['ANALYTICS_TOKEN'].present? %>
<script>
@@ -84,5 +84,14 @@
})(document,"script");
</script>
<% end %>
<% if ENV.fetch('MS_CLARITY_TOKEN', nil).present? %>
<script type="text/javascript">
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src='https://www.clarity.ms/tag/'+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, 'clarity', 'script', '<%= ENV.fetch('MS_CLARITY_TOKEN', '') %>');
</script>
<% end %>
</body>
</html>
@@ -35,7 +35,9 @@
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center pt-2">
<div class="flex items-center space-x-2">
<% if @article.author&.avatar_url&.present? %>
<img src="<%= @article.author.avatar_url %>" alt="<%= @article.author.display_name %>" class="w-12 h-12 border rounded-full pr-1">
<div class="pr-1">
<img src="<%= @article.author.avatar_url %>" alt="<%= @article.author.display_name %>" class="w-12 h-12 border rounded-full">
</div>
<% end %>
<div>
<h5 class="text-base font-medium text-slate-900 mb-2"><%= @article.author.available_name %></h5>
@@ -0,0 +1,9 @@
<section class="main-content__body">
<hr/>
<%= form_for([:reset_cache, namespace, page.resource], method: :post, html: { class: "form" }) do |f| %>
<div class="form-actions">
<p>This will clear the IndexedDB cache keys from redis. <br>The next load will fetch the data from backend.</p>
<%= f.submit 'Reset Frontend Cache' %>
</div>
<% end %>
</section>
@@ -87,3 +87,5 @@ as well as a link to its edit page.
</section>
<%= render partial: "seed_data", locals: {page: page} %>
<%= render partial: "reset_cache", locals: {page: page} %>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '2.17.0'
version: '2.17.1'
development:
<<: *shared
+24
View File
@@ -48,6 +48,10 @@ class Rack::Attack
throttle('req/ip', limit: 300, period: 1.minute, &:ip)
###-----------------------------------------------###
###-----Authentication Related Throttling---------###
###-----------------------------------------------###
### Prevent Brute-Force Super Admin Login Attacks ###
throttle('super_admin_login/ip', limit: 5, period: 5.minutes) do |req|
req.ip if req.path_without_extentions == '/super_admin/sign_in' && req.post?
@@ -95,6 +99,12 @@ class Rack::Attack
req.ip if req.path_without_extentions == '/api/v1/accounts' && req.post?
end
##-----------------------------------------------##
###-----------------------------------------------###
###-----------Widget API Throttling---------------###
###-----------------------------------------------###
## Prevent Conversation Bombing on Widget APIs ###
throttle('api/v1/widget/conversations', limit: 6, period: 12.hours) do |req|
req.ip if req.path_without_extentions == '/api/v1/widget/conversations' && req.post?
@@ -109,6 +119,20 @@ class Rack::Attack
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req|
req.ip if req.path_without_extentions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
end
##-----------------------------------------------##
###-----------------------------------------------###
###----------Application API Throttling-----------###
###-----------------------------------------------###
## Prevent Abuse of Converstion Transcript APIs ###
throttle('/api/v1/accounts/:account_id/conversations/:conversation_id/transcript', limit: 20, period: 1.hour) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/conversations/(?<conversation_id>\d+)/transcript}.match(req.path)
match_data[:account_id] if match_data.present?
end
## ----------------------------------------------- ##
end
# Log blocked events
+2
View File
@@ -70,6 +70,8 @@ en:
connection_closed_error: Connection closed.
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
reports:
period: Reporting period %{since} to %{until}
+1
View File
@@ -397,6 +397,7 @@ Rails.application.routes.draw do
# order of resources affect the order of sidebar navigation in super admin
resources :accounts, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
post :seed, on: :member
post :reset_cache, on: :member
end
resources :users, only: [:index, :new, :create, :show, :edit, :update, :destroy]
resources :access_tokens, only: [:index, :show]
@@ -0,0 +1,8 @@
class AddPartialIndexForResolvedContacts < ActiveRecord::Migration[7.0]
disable_ddl_transaction!
def change
add_index :contacts, [:account_id, :email, :phone_number, :identifier], where: "(email <> '' OR phone_number <> '' OR identifier <> '')",
name: 'index_contacts_on_nonempty_fields', algorithm: :concurrently
end
end
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_05_15_051424) do
ActiveRecord::Schema[7.0].define(version: 2023_05_23_104139) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -405,6 +405,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_15_051424) do
t.jsonb "custom_attributes", default: {}
t.datetime "last_activity_at", precision: nil
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id"], name: "index_contacts_on_account_id"
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
+2 -1
View File
@@ -36,7 +36,7 @@ COPY Gemfile Gemfile.lock ./
# https://github.com/googleapis/google-cloud-ruby/issues/13306
# adding xz as nokogiri was failing to build libxml
# https://github.com/chatwoot/chatwoot/issues/4045
RUN apk update && apk add --no-cache build-base musl ruby-full ruby-dev gcc make musl-dev openssl openssl-dev g++ linux-headers xz
RUN apk update && apk add --no-cache build-base musl ruby-full ruby-dev gcc make musl-dev openssl openssl-dev g++ linux-headers xz vips
RUN bundle config set --local force_ruby_platform true
# Do not install development or test gems in production
@@ -97,6 +97,7 @@ RUN apk update && apk add --no-cache \
postgresql-client \
imagemagick \
git \
vips \
&& gem install bundler
RUN if [ "$RAILS_ENV" != "production" ]; then \
@@ -0,0 +1,25 @@
module Enterprise::DeviseOverrides::SessionsController
def render_create_success
create_audit_event('sign_in')
super
end
def destroy
create_audit_event('sign_out')
super
end
def create_audit_event(action)
return unless @resource
associated_type = 'Account'
@resource.accounts.each do |account|
@resource.audits.create(
action: action,
user_id: @resource.id,
associated_id: account.id,
associated_type: associated_type
)
end
end
end
@@ -0,0 +1,13 @@
module Enterprise::Audit::User
extend ActiveSupport::Concern
included do
audited only: [
:availability,
:display_name,
:email,
:name
]
audited associated_with: :account
end
end
+8 -4
View File
@@ -13,11 +13,15 @@ module.exports = {
'^.+\\.(js|jsx)?$': 'babel-jest',
},
cacheDirectory: '<rootDir>/.jest-cache',
collectCoverage: false,
coverageDirectory: 'buildreports',
collectCoverageFrom: ['**/app/javascript/**/*.{js,vue}'],
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov'],
collectCoverageFrom: [
'**/app/javascript/**/*.js',
'!**/*.stories.js',
'!**/i18n/locale/**/*.js',
],
reporters: ['default'],
// setupTestFrameworkScriptFile: './tests/setup.ts',
transformIgnorePatterns: ['node_modules/*'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/app/javascript/$1',
+1 -1
View File
@@ -12,7 +12,7 @@ module ExceptionList
].freeze
IMAP_EXCEPTIONS = [
Errno::ECONNREFUSED, Net::OpenTimeout, Net::IMAP::NoResponseError,
Errno::ECONNREFUSED, Net::OpenTimeout,
Errno::ECONNRESET, Errno::ENETUNREACH, Net::IMAP::ByeResponseError,
SocketError
].freeze
+1
View File
@@ -1,3 +1,4 @@
module Limits
BULK_ACTIONS_LIMIT = 100
BULK_EXTERNAL_HTTP_CALLS_LIMIT = 25
end
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "2.17.0",
"version": "2.17.1",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -27,7 +27,9 @@ RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/contact_inboxes', typ
end.to change(ContactInbox, :count).by(1)
expect(response).to have_http_status(:success)
expect(contact.reload.contact_inboxes.map(&:inbox_id)).to include(channel_api.inbox.id)
contact_inbox = contact.reload.contact_inboxes.find_by(inbox_id: channel_api.inbox.id)
expect(contact_inbox).to be_present
expect(contact_inbox.hmac_verified).to be(false)
end
it 'creates a valid email contact inbox' do
@@ -43,6 +45,21 @@ RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/contact_inboxes', typ
expect(contact.reload.contact_inboxes.map(&:inbox_id)).to include(channel_email.inbox.id)
end
it 'creates an hmac verified contact inbox' do
create(:inbox_member, inbox: channel_api.inbox, user: agent)
expect do
post "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/contact_inboxes",
params: { inbox_id: channel_api.inbox.id, hmac_verified: true },
headers: agent.create_new_auth_token,
as: :json
end.to change(ContactInbox, :count).by(1)
expect(response).to have_http_status(:success)
contact_inbox = contact.reload.contact_inboxes.find_by(inbox_id: channel_api.inbox.id)
expect(contact_inbox).to be_present
expect(contact_inbox.hmac_verified).to be(true)
end
it 'throws error for invalid source id' do
create(:inbox_member, inbox: channel_twilio_sms.inbox, user: agent)
expect do
@@ -273,21 +273,6 @@ RSpec.describe 'Conversations API', type: :request do
expect(response_data[:status]).to eq('pending')
end
# TODO: remove this spec when we remove the condition check in controller
# Added for backwards compatibility for bot status
# remove this in subsequent release
# it 'creates a conversation as pending if status is specified as bot' do
# allow(Rails.configuration.dispatcher).to receive(:dispatch)
# post "/api/v1/accounts/#{account.id}/conversations",
# headers: agent.create_new_auth_token,
# params: { source_id: contact_inbox.source_id, status: 'bot' },
# as: :json
# expect(response).to have_http_status(:success)
# response_data = JSON.parse(response.body, symbolize_names: true)
# expect(response_data[:status]).to eq('pending')
# end
it 'creates a new conversation with message when message is passed' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post "/api/v1/accounts/#{account.id}/conversations",
@@ -304,13 +289,13 @@ RSpec.describe 'Conversations API', type: :request do
it 'calls contact inbox builder if contact_id and inbox_id is present' do
builder = double
allow(Rails.configuration.dispatcher).to receive(:dispatch)
allow(ContactInboxBuilder).to receive(:new).and_return(builder)
allow(ContactInboxBuilder).to receive(:new).with(contact: contact, inbox: inbox, source_id: nil, hmac_verified: false).and_return(builder)
allow(builder).to receive(:perform)
expect(builder).to receive(:perform)
post "/api/v1/accounts/#{account.id}/conversations",
headers: agent.create_new_auth_token,
params: { contact_id: contact.id, inbox_id: inbox.id },
params: { contact_id: contact.id, inbox_id: inbox.id, hmac_verified: 'false' },
as: :json
end
@@ -66,6 +66,8 @@ RSpec.describe 'Custom Filters API', type: :request do
context 'when it is an authenticated user' do
let(:user) { create(:user, account: account) }
let(:new_account) { create(:account) }
let(:new_user) { create(:user, account: new_account) }
it 'creates the filter' do
expect do
@@ -77,6 +79,23 @@ RSpec.describe 'Custom Filters API', type: :request do
json_response = response.parsed_body
expect(json_response['name']).to eq 'vip-customers'
end
it 'gives the error for 51st record' do
CustomFilter::MAX_FILTER_PER_USER.times do
create(:custom_filter, user: user, account: account)
end
expect do
post "/api/v1/accounts/#{account.id}/custom_filters", headers: user.create_new_auth_token,
params: payload
end.not_to change(CustomFilter, :count)
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['message']).to include(
'Account Limit reached. The maximum number of allowed custom filters for a user per account is 50.'
)
end
end
end
@@ -469,17 +469,37 @@ RSpec.describe 'Inboxes API', type: :request do
expect(api_channel.reload.webhook_url).to eq('webhook.test')
end
it 'updates twitter inbox when administrator' do
api_channel = create(:channel_twitter_profile, account: account, tweets_enabled: true)
api_inbox = create(:inbox, channel: api_channel, account: account)
it 'updates whatsapp inbox when administrator' do
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook').to_return(status: 200, body: '', headers: {})
stub_request(:get, 'https://waba.360dialog.io/v1/configs/templates').to_return(status: 200, body: '', headers: {})
whatsapp_channel = create(:channel_whatsapp, account: account)
whatsapp_inbox = create(:inbox, channel: whatsapp_channel, account: account)
whatsapp_channel.prompt_reauthorization!
patch "/api/v1/accounts/#{account.id}/inboxes/#{api_inbox.id}",
expect(whatsapp_channel).to be_reauthorization_required
patch "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}",
headers: admin.create_new_auth_token,
params: { enable_auto_assignment: false, channel: { provider_config: { api_key: 'new_key' } } },
as: :json
expect(response).to have_http_status(:success)
expect(whatsapp_inbox.reload.enable_auto_assignment).to be_falsey
expect(whatsapp_channel.reload.provider_config['api_key']).to eq('new_key')
expect(whatsapp_channel.reload).not_to be_reauthorization_required
end
it 'updates twitter inbox when administrator' do
twitter_channel = create(:channel_twitter_profile, account: account, tweets_enabled: true)
twitter_inbox = create(:inbox, channel: twitter_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{twitter_inbox.id}",
headers: admin.create_new_auth_token,
params: { channel: { tweets_enabled: false } },
as: :json
expect(response).to have_http_status(:success)
expect(api_channel.reload.tweets_enabled).to be(false)
expect(twitter_channel.reload.tweets_enabled).to be(false)
end
it 'updates email inbox when administrator' do
@@ -15,8 +15,6 @@ RSpec.describe 'Super Admin accounts API', type: :request do
end
context 'when it is an authenticated user' do
let!(:account) { create(:account) }
it 'shows the list of accounts' do
sign_in(super_admin, scope: :super_admin)
get '/super_admin/accounts'
@@ -27,6 +25,32 @@ RSpec.describe 'Super Admin accounts API', type: :request do
end
end
describe 'POST /super_admin/accounts/{account_id}/reset_cache' do
before do
create(:label, account: account)
create(:inbox, account: account)
create(:team, account: account)
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/super_admin/accounts/#{account.id}/reset_cache"
expect(response).to have_http_status(:redirect)
end
end
context 'when it is an authenticated user' do
it 'shows the list of accounts' do
expect(account.cache_keys.keys).to contain_exactly(:inbox, :label, :team)
sign_in(super_admin, scope: :super_admin)
post "/super_admin/accounts/#{account.id}/reset_cache"
expect(response).to have_http_status(:redirect)
expect(flash[:notice]).to eq('Cache keys cleared')
expect(account.reload.cache_keys.values.map(&:to_i)).to eq([0, 0, 0])
end
end
end
describe 'DELETE /super_admin/accounts/{account_id}' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -0,0 +1,53 @@
require 'rails_helper'
RSpec.describe 'Enterprise Audit API', type: :request do
let!(:account) { create(:account) }
let!(:user) { create(:user, password: 'Password1!', account: account) }
describe 'POST /sign_in' do
it 'creates a sign_in audit event wwith valid credentials' do
params = { email: user.email, password: 'Password1!' }
expect do
post new_user_session_url,
params: params,
as: :json
end.to change(Enterprise::AuditLog, :count).by(1)
expect(response).to have_http_status(:success)
expect(response.body).to include(user.email)
# Check if the sign_in event is created
user.reload
expect(user.audits.last.action).to eq('sign_in')
expect(user.audits.last.associated_id).to eq(account.id)
expect(user.audits.last.associated_type).to eq('Account')
end
it 'will not create a sign_in audit event with invalid credentials' do
params = { email: user.email, password: 'invalid' }
expect do
post new_user_session_url,
params: params,
as: :json
end.not_to change(Enterprise::AuditLog, :count)
end
end
describe 'DELETE /sign_out' do
context 'when it is an authenticated user' do
it 'signs out the user and creates an audit event' do
expect do
delete '/auth/sign_out', headers: user.create_new_auth_token
end.to change(Enterprise::AuditLog, :count).by(1)
expect(response).to have_http_status(:success)
user.reload
expect(user.audits.last.action).to eq('sign_out')
expect(user.audits.last.associated_id).to eq(account.id)
expect(user.audits.last.associated_type).to eq('Account')
end
end
end
end
@@ -11,7 +11,7 @@ RSpec.describe Channels::Whatsapp::TemplatesSyncSchedulerJob do
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
non_synced = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: nil)
synced_recently = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: Time.zone.now)
synced_old = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: 16.minutes.ago)
synced_old = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: 4.hours.ago)
described_class.perform_now
expect(Channels::Whatsapp::TemplatesSyncJob).not_to(
have_been_enqueued.with(synced_recently).on_queue('low')
+8 -7
View File
@@ -22,18 +22,19 @@ RSpec.describe HookJob do
allow(process_service).to receive(:perform)
end
it 'calls Integrations::Slack::SendOnSlackService when its a slack hook' do
it 'calls SendOnSlackJob when its a slack hook' do
hook = create(:integrations_hook, app_id: 'slack', account: account)
allow(Integrations::Slack::SendOnSlackService).to receive(:new).and_return(process_service)
expect(Integrations::Slack::SendOnSlackService).to receive(:new).with(message: event_data[:message], hook: hook)
allow(SendOnSlackJob).to receive(:perform_later).and_return(process_service)
expect(SendOnSlackJob).to receive(:perform_later).with(event_data[:message], hook)
described_class.perform_now(hook, event_name, event_data)
end
it 'calls Integrations::Slack::SendOnSlackService when its a slack hook for template message' do
event_data = { message: create(:message, account: account, message_type: :template) }
it 'calls SendOnSlackJob when its a slack hook for message with attachments' do
event_data = { message: create(:message, :with_attachment, account: account) }
hook = create(:integrations_hook, app_id: 'slack', account: account)
allow(Integrations::Slack::SendOnSlackService).to receive(:new).and_return(process_service)
expect(Integrations::Slack::SendOnSlackService).to receive(:new)
allow(SendOnSlackJob).to receive(:set).with(wait: 2.seconds).and_return(SendOnSlackJob)
allow(SendOnSlackJob).to receive(:perform_later).and_return(process_service)
expect(SendOnSlackJob).to receive(:perform_later).with(event_data[:message], hook)
described_class.perform_now(hook, event_name, event_data)
end
+35
View File
@@ -0,0 +1,35 @@
require 'rails_helper'
RSpec.describe SendOnSlackJob do
let(:account) { create(:account) }
let(:hook) { create(:integrations_hook, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:event_name) { 'message.created' }
let(:event_data) { { message: create(:message, account: account, content: 'muchas muchas gracias', message_type: :incoming) } }
context 'when handleable events like message.created' do
let(:process_service) { double }
before do
stub_request(:post, 'https://slack.com/api/chat.postMessage')
allow(process_service).to receive(:perform)
end
it 'calls Integrations::Slack::SendOnSlackService when its a slack hook' do
hook = create(:integrations_hook, app_id: 'slack', account: account)
slack_service_instance = Integrations::Slack::SendOnSlackService.new(message: event_data[:message], hook: hook)
expect(Integrations::Slack::SendOnSlackService).to receive(:new).with(message: event_data[:message],
hook: hook).and_return(slack_service_instance)
described_class.perform_now(event_data[:message], hook)
end
it 'calls Integrations::Slack::SendOnSlackService when its a slack hook for template message' do
event_data = { message: create(:message, account: account, message_type: :template) }
hook = create(:integrations_hook, app_id: 'slack', account: account)
slack_service_instance = Integrations::Slack::SendOnSlackService.new(message: event_data[:message], hook: hook)
expect(Integrations::Slack::SendOnSlackService).to receive(:new).with(message: event_data[:message],
hook: hook).and_return(slack_service_instance)
described_class.perform_now(event_data[:message], hook)
end
end
end
@@ -27,6 +27,7 @@ RSpec.describe 'Confirmation Instructions' do
it 'does not refer to the inviter and their account' do
expect(mail.body).to_not match('has invited you to try out Chatwoot!')
expect(mail.body).to match('We have a suite of powerful tools ready for you to explore.')
end
it 'sends a confirmation link' do
@@ -41,6 +42,7 @@ RSpec.describe 'Confirmation Instructions' do
expect(mail.body).to match(
"#{CGI.escapeHTML(inviter_val.name)}, with #{CGI.escapeHTML(account.name)}, has invited you to try out Chatwoot."
)
expect(mail.body).not_to match('We have a suite of powerful tools ready for you to explore.')
end
it 'sends a password reset link' do
+73
View File
@@ -0,0 +1,73 @@
require 'rails_helper'
RSpec.describe CacheKeys do
let(:test_model) do
Struct.new(:id) do
include CacheKeys
def fetch_value_for_key(_id, _key)
'value'
end
end.new(1)
end
before do
allow(Redis::Alfred).to receive(:delete)
allow(Redis::Alfred).to receive(:set)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
describe '#cache_keys' do
it 'returns a hash of cache keys' do
expected_keys = test_model.class.cacheable_models.map do |model|
[model.name.underscore.to_sym, 'value']
end.to_h
expect(test_model.cache_keys).to eq(expected_keys)
end
end
describe '#invalidate_cache_key_for' do
it 'deletes the cache key' do
test_model.invalidate_cache_key_for('label')
expect(Redis::Alfred).to have_received(:delete).with('idb-cache-key-account-1-label')
end
it 'dispatches a cache update event' do
test_model.invalidate_cache_key_for('label')
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
CacheKeys::ACCOUNT_CACHE_INVALIDATED,
kind_of(ActiveSupport::TimeWithZone),
cache_keys: test_model.cache_keys,
account: test_model
)
end
end
describe '#update_cache_key' do
it 'updates the cache key' do
allow(Time).to receive(:now).and_return(Time.parse('2023-05-29 00:00:00 UTC'))
test_model.update_cache_key('label')
expect(Redis::Alfred).to have_received(:set).with('idb-cache-key-account-1-label', Time.now.utc.to_i)
end
it 'dispatches a cache update event' do
test_model.update_cache_key('label')
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
CacheKeys::ACCOUNT_CACHE_INVALIDATED,
kind_of(ActiveSupport::TimeWithZone),
cache_keys: test_model.cache_keys,
account: test_model
)
end
end
describe '#reset_cache_keys' do
it 'invalidates all cache keys for cacheable models' do
test_model.reset_cache_keys
test_model.class.cacheable_models.each do |model|
expect(Redis::Alfred).to have_received(:delete).with("idb-cache-key-account-1-#{model.name.underscore}")
end
end
end
end
+26
View File
@@ -59,6 +59,32 @@ RSpec.describe Notification do
#{message.content.truncate_words(10)}"
end
it 'returns appropriate title suited for the notification type participating_conversation_new_message having mention' do
message = create(:message, sender: create(:user), content: 'Hey [@John](mention://user/1/john), can you check this ticket?')
notification = create(:notification, notification_type: 'participating_conversation_new_message', primary_actor: message,
secondary_actor: message.sender)
expect(notification.push_message_title).to eq "[New message] - ##{notification.conversation.display_id} Hey @John, can you check this ticket?"
end
it 'returns appropriate title suited for the notification type participating_conversation_new_message having multple mention' do
message = create(:message, sender: create(:user),
content: 'Hey [@John](mention://user/1/john), [@Alisha Peter](mention://user/2/alisha) can you check this ticket?')
notification = create(:notification, notification_type: 'participating_conversation_new_message', primary_actor: message,
secondary_actor: message.sender)
expect(notification.push_message_title).to eq "[New message] - ##{notification.conversation.display_id} \
Hey @John, @Alisha Peter can you check this ticket?"
end
it 'returns appropriate title suited for the notification type participating_conversation_new_message if username contains white space' do
message = create(:message, sender: create(:user), content: 'Hey [@John Peter](mention://user/1/john%20K) please check this?')
notification = create(:notification, notification_type: 'participating_conversation_new_message', primary_actor: message,
secondary_actor: message.sender)
expect(notification.push_message_title).to eq "[New message] - ##{notification.conversation.display_id} Hey @John Peter please check this?"
end
it 'returns appropriate title suited for the notification type conversation_mention' do
message = create(:message, sender: create(:user), content: 'Hey [@John](mention://user/1/john), can you check this ticket?')
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: message, secondary_actor: message.sender)
+16
View File
@@ -34,6 +34,22 @@ RSpec.describe User do
it { expect(user.pubsub_token).not_to be_nil }
it { expect(user.saved_changes.keys).not_to eq('pubsub_token') }
context 'rotates the pubsub_token' do
it 'changes the pubsub_token when password changes' do
pubsub_token = user.pubsub_token
user.password = Faker::Internet.password(special_characters: true)
user.save!
expect(user.pubsub_token).not_to eq(pubsub_token)
end
it 'will not change pubsub_token when other attributes change' do
pubsub_token = user.pubsub_token
user.name = Faker::Name.name
user.save!
expect(user.pubsub_token).to eq(pubsub_token)
end
end
end
describe 'hmac_identifier' do
@@ -0,0 +1,21 @@
## the specs are covered in send in spec/services/whatsapp/send_on_whatsapp_service_spec.rb
require 'rails_helper'
describe Whatsapp::Providers::Whatsapp360DialogService do
subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) }
let!(:whatsapp_channel) { create(:channel_whatsapp, sync_templates: false, validate_provider_config: false) }
describe '#sync_templates' do
context 'when called' do
it 'updates message_templates_last_updated even when template request fails' do
stub_request(:get, 'https://waba.360dialog.io/v1/configs/templates')
.to_return(status: 401)
timstamp = whatsapp_channel.reload.message_templates_last_updated
subject.sync_templates
expect(whatsapp_channel.reload.message_templates_last_updated).not_to eq(timstamp)
end
end
end
end
@@ -1 +0,0 @@
## the specs are covered in send in spec/services/whatsapp/send_on_whatsapp_service_spec.rb
@@ -127,10 +127,21 @@ describe Whatsapp::Providers::WhatsappCloudService do
], paging: { prev: 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key' } }.to_json }
)
timstamp = whatsapp_channel.reload.message_templates_last_updated
expect(subject.sync_templates).to be(true)
expect(whatsapp_channel.reload.message_templates.first).to eq({ id: '123456789', name: 'test_template' }.stringify_keys)
expect(whatsapp_channel.reload.message_templates.second).to eq({ id: '123456789', name: 'next_template' }.stringify_keys)
expect(whatsapp_channel.reload.message_templates.last).to eq({ id: '123456789', name: 'last_template' }.stringify_keys)
expect(whatsapp_channel.reload.message_templates_last_updated).not_to eq(timstamp)
end
it 'updates message_templates_last_updated even when template request fails' do
stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key')
.to_return(status: 401)
timstamp = whatsapp_channel.reload.message_templates_last_updated
subject.sync_templates
expect(whatsapp_channel.reload.message_templates_last_updated).not_to eq(timstamp)
end
end
end

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