Compare commits

..
123 changed files with 572 additions and 1607 deletions
+3 -19
View File
@@ -2,14 +2,6 @@
# It initializes with necessary attributes and provides a perform method
# to create a user and account user in a transaction.
class AgentBuilder
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
class LimitExceededError < StandardError
def initialize
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
end
end
# Initializes an AgentBuilder with necessary attributes.
# @param email [String] the email of the user.
# @param name [String] the name of the user.
@@ -22,23 +14,15 @@ class AgentBuilder
# Creates a user and account user in a transaction.
# @return [User] the created user.
def perform
account.with_lock do
raise LimitExceededError unless can_add_agent?
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
end
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
end
@user
end
private
def can_add_agent?
account.usage_limits[:agents] > account.account_users.count
end
# Finds a user by email or creates a new one with a temporary password.
# @return [User] the found or created user.
def find_or_create_user
@@ -3,8 +3,6 @@ class Campaigns::CampaignConversationBuilder
def perform
@contact_inbox = ContactInbox.find(@contact_inbox_id)
return unless @contact_inbox.inbox.active?
@campaign = @contact_inbox.inbox.campaigns.find_by!(display_id: campaign_display_id)
ActiveRecord::Base.transaction do
-2
View File
@@ -2,8 +2,6 @@ class ConversationBuilder
pattr_initialize [:params!, :contact_inbox!]
def perform
raise CustomExceptions::InboxDisabled unless @contact_inbox.inbox.active?
look_up_exising_conversation || create_new_conversation
end
@@ -20,7 +20,6 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def perform
# This channel might require reauthorization, may be owner might have changed the fb password
return if @inbox.channel.reauthorization_required?
return unless @inbox.active?
ActiveRecord::Base.transaction do
build_contact_inbox
@@ -10,7 +10,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
def perform
return if @inbox.channel.reauthorization_required?
return unless @inbox.active?
ActiveRecord::Base.transaction do
build_message
+2 -3
View File
@@ -22,15 +22,14 @@ class Messages::MessageBuilder
end
def perform
raise CustomExceptions::InboxDisabled unless @conversation.inbox.active?
@message = @conversation.messages.build(message_params)
process_attachments
process_emails
# When the message has no quoted content, it will just be rendered as a regular message
# The frontend is equipped to handle this case
process_email_content
@message.tap(&:save!)
@message.save!
@message
end
private
@@ -1,6 +1,8 @@
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index, :bulk_create]
before_action :check_authorization
before_action :validate_limit, only: [:create]
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
def index
@agents = agents
@@ -18,8 +20,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
)
@agent = builder.perform
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end
def update
@@ -36,13 +36,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def bulk_create
emails = params[:emails]
bulk_create_agents(emails)
emails.each do |email|
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
begin
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
end
# This endpoint is used to bulk create agents during onboarding
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
clear_onboarding_step
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
head :ok
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end
private
@@ -75,33 +87,22 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end
def bulk_create_agents(emails)
Current.account.with_lock do
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
def validate_limit_for_bulk_create
limit_available = params[:emails].count <= available_agent_count
emails.each { |email| create_agent_from_email(email) }
end
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
end
def create_agent_from_email(email)
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
def clear_onboarding_step
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
def validate_limit
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
end
def available_agent_count
Current.account.usage_limits[:agents] - Current.account.account_users.count
Current.account.usage_limits[:agents] - agents.count
end
def can_add_agent?
available_agent_count.positive?
end
def delete_user_record(agent)
@@ -1,6 +1,5 @@
class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController
before_action :ensure_api_inbox, only: :update
before_action :ensure_inbox_active, only: [:create, :retry]
def index
@messages = message_finder.perform
@@ -10,8 +9,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
user = Current.user || @resource
mb = Messages::MessageBuilder.new(user, @conversation, params)
@message = mb.perform
rescue CustomExceptions::InboxDisabled
render_inbox_disabled_error
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -35,8 +32,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
service.perform
message.update!(content_attributes: {})
::SendReplyJob.perform_later(message.id)
rescue CustomExceptions::InboxDisabled
render_inbox_disabled_error
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -85,8 +80,4 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
# Only API inboxes can update messages
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
end
def ensure_inbox_active
render_inbox_disabled_error unless @conversation.inbox.active?
end
end
@@ -198,7 +198,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
render_inbox_disabled_error unless @inbox.active?
end
def contact
@@ -215,7 +214,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# and deprecate the support of passing only source_id as the param
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
authorize @contact_inbox.inbox, :show?
render_inbox_disabled_error unless @contact_inbox.inbox.active?
rescue ActiveRecord::RecordNotUnique
render json: { error: 'source_id should be unique' }, status: :unprocessable_entity
end
@@ -223,7 +221,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def build_contact_inbox
return if @inbox.blank? || @contact.blank?
ContactInboxBuilder.new(contact: @contact, inbox: @inbox, source_id: params[:source_id], hmac_verified: hmac_verified?).perform
ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: params[:source_id],
hmac_verified: hmac_verified?
).perform
end
def conversation_finder
@@ -193,7 +193,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
def inbox_attributes
[:name, :avatar, :active, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language,
@@ -1,9 +0,0 @@
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
private
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
def check_authorization
authorize(:hook)
end
end
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
before_action :fetch_hook, except: [:create]
before_action :check_authorization
@@ -35,6 +35,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
@hook = Current.account.hooks.find(params[:id])
end
def check_authorization
authorize(:hook)
end
def permitted_params
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
end
@@ -1,7 +1,6 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy
revoke_linear_token
@@ -1,6 +1,5 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy
@hook.destroy!
@@ -1,8 +1,7 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
include Shopify::IntegrationHelper
before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth]
before_action :check_authorization, only: [:destroy]
before_action :validate_contact, only: [:orders]
def auth
@@ -90,8 +90,4 @@ class Api::V1::Widget::BaseController < ApplicationController
message_type: :incoming
}
end
def ensure_inbox_active
render_inbox_disabled_error unless @web_widget.inbox.active?
end
end
@@ -1,5 +1,4 @@
class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
before_action :ensure_inbox_active
before_action :set_global_config
def create
@@ -1,7 +1,6 @@
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
include WidgetHelper
before_action :ensure_inbox_active, only: [:update, :set_user, :destroy_custom_attributes]
before_action :validate_hmac, only: [:set_user]
before_action :validate_hmac_for_identified_update, only: [:update]
@@ -1,12 +1,6 @@
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
include Events::Types
DISABLED_INBOX_ACTIONS = [
:create, :toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes, :transcript
].freeze
before_action :render_not_found_if_empty, only: [:toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes]
before_action :ensure_inbox_active, only: DISABLED_INBOX_ACTIONS
def index
@conversation = conversation
@@ -1,7 +1,6 @@
class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsController
include WebsiteTokenHelper
before_action :set_web_widget
before_action :ensure_inbox_active
before_action :set_contact
def create
@@ -9,15 +8,4 @@ class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsCon
super
end
private
def ensure_inbox_active
return if @web_widget.inbox.active?
render json: {
error: 'inbox_disabled',
message: 'This inbox is currently disabled'
}, status: :forbidden
end
end
@@ -1,8 +1,6 @@
class Api::V1::Widget::EventsController < Api::V1::Widget::BaseController
include Events::Types
before_action :ensure_inbox_active
def create
Rails.configuration.dispatcher.dispatch(permitted_params[:name], Time.zone.now, contact_inbox: @contact_inbox,
event_info: permitted_params[:event_info].to_h.merge(event_info))
@@ -1,5 +1,4 @@
class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseController
before_action :ensure_inbox_active
before_action :set_message
def add_participant_to_meeting
@@ -1,6 +1,4 @@
class Api::V1::Widget::LabelsController < Api::V1::Widget::BaseController
before_action :ensure_inbox_active
def create
if conversation.present? && label_defined_in_account?
conversation.label_list.add(permitted_params[:label])
@@ -1,5 +1,4 @@
class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
before_action :ensure_inbox_active, only: [:create, :update]
before_action :set_conversation, only: [:create]
before_action :set_message, only: [:update]
@@ -9,7 +9,6 @@ module RequestExceptionHandler
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from CustomExceptions::InboxDisabled, with: :render_inbox_disabled_error
rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
end
@@ -46,13 +45,6 @@ module RequestExceptionHandler
render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
end
def render_inbox_disabled_error(_exception = nil)
render json: {
error: 'inbox_disabled',
message: 'This inbox is currently disabled'
}, status: :forbidden
end
def render_payment_required(message)
render json: { error: message }, status: :payment_required
end
@@ -1,34 +0,0 @@
class Platform::Api::V1::InboxesController < PlatformController
before_action :set_account
before_action :validate_account_permissible
before_action :set_inbox
def disable
update_active_state(false)
end
def enable
update_active_state(true)
end
private
def set_account
@account = Account.find(params[:account_id])
end
def validate_account_permissible
return if @platform_app.platform_app_permissibles.find_by(permissible: @account)
render json: { error: 'Non permissible resource' }, status: :unauthorized
end
def set_inbox
@inbox = @account.inboxes.find(params[:id])
end
def update_active_state(active)
@inbox.update!(active: active)
render json: { success: true, inbox_id: @inbox.id, active: @inbox.active }
end
end
@@ -1,6 +1,5 @@
class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesController
before_action :contact_inbox, except: [:create]
before_action :ensure_inbox_active, only: [:create, :update]
before_action :process_hmac
def show; end
@@ -51,8 +50,4 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
def permitted_params
params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {})
end
def ensure_inbox_active
render_inbox_disabled_error unless @inbox_channel.inbox.active?
end
end
@@ -1,7 +1,6 @@
class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::InboxesController
include Events::Types
before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status]
before_action :ensure_inbox_active, only: [:create, :toggle_typing, :toggle_status]
def index
@conversations = @contact_inbox.hmac_verified? ? @contact_inbox.contact.conversations : @contact_inbox.conversations
@@ -65,8 +64,4 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
def conversation_params
params.permit(custom_attributes: {})
end
def ensure_inbox_active
render_inbox_disabled_error unless @contact_inbox.inbox.active?
end
end
@@ -1,6 +1,5 @@
class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesController
before_action :set_message, only: [:update]
before_action :ensure_inbox_active, only: [:create, :update]
def index
@messages = @conversation.nil? ? [] : message_finder.perform
@@ -71,8 +70,4 @@ class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesCon
def check_csat_locked
(Time.zone.now.to_date - @message.created_at.to_date).to_i > 14 and @message.content_type == 'input_csat'
end
def ensure_inbox_active
render_inbox_disabled_error unless @conversation.inbox.active?
end
end
@@ -47,10 +47,18 @@ class Webhooks::WhatsappController < ActionController::API
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
return if metadata.blank?
Whatsapp::WebhookChannelFinderService.new(
display_phone_number: metadata[:display_phone_number],
phone_number_id: metadata[:phone_number_id]
).perform
phone_number = normalized_phone_number(metadata[:display_phone_number])
phone_number_id = metadata[:phone_number_id]
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
def normalized_phone_number(phone_number)
return if phone_number.blank?
phone_number = phone_number.to_s
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
end
def inactive_whatsapp_number?
-5
View File
@@ -5,7 +5,6 @@ class WidgetsController < ActionController::Base
before_action :set_global_config
before_action :set_web_widget
before_action :ensure_account_is_active
before_action :ensure_inbox_active
before_action :ensure_location_is_supported
before_action :set_token
before_action :set_contact
@@ -63,10 +62,6 @@ class WidgetsController < ActionController::Base
render json: { error: 'Account is suspended' }, status: :unauthorized unless @web_widget.inbox.account.active?
end
def ensure_inbox_active
head :not_found unless @web_widget.inbox.active?
end
def ensure_location_is_supported; end
def additional_attributes
@@ -1,8 +1,7 @@
<script setup>
import { computed, useSlots, ref } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
@@ -23,11 +22,8 @@ const props = defineProps({
const emit = defineEmits(['goToContactsList', 'toggleBlock']);
const { t } = useI18n();
const slots = useSlots();
const route = useRoute();
const isContactSidebarOpen = ref(false);
const contactId = computed(() => route.params.contactId);
const selectedContactName = computed(() => {
@@ -60,26 +56,15 @@ const handleBreadcrumbClick = () => {
const toggleBlock = () => {
emit('toggleBlock', isContactBlocked.value);
};
const handleConversationSidebarToggle = () => {
isContactSidebarOpen.value = !isContactSidebarOpen.value;
};
const closeMobileSidebar = () => {
if (!isContactSidebarOpen.value) return;
isContactSidebarOpen.value = false;
};
</script>
<template>
<section
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<header class="sticky top-0 z-10 px-6 3xl:px-0">
<div class="w-full mx-auto max-w-[40.625rem]">
<div class="w-full mx-auto max-w-5xl">
<div
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
>
@@ -119,81 +104,10 @@ const closeMobileSidebar = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
<div class="w-full py-4 mx-auto max-w-5xl">
<slot name="default" />
</div>
</main>
</div>
<!-- Desktop sidebar -->
<div
v-if="slots.sidebar"
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
</div>
<!-- Mobile sidebar container -->
<div
v-if="slots.sidebar"
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
:class="isContactSidebarOpen ? 'w-full' : 'w-16'"
>
<!-- Toggle button -->
<div
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#contact-sidebar-content'] },
]"
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
:class="[
isContactSidebarOpen
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
]"
>
<Button
ghost
slate
sm
class="!rounded-full rtl:rotate-180"
:class="{ 'bg-n-alpha-2': isContactSidebarOpen }"
:icon="
isContactSidebarOpen
? 'i-lucide-panel-right-close'
: 'i-lucide-panel-right-open'
"
data-contact-sidebar-toggle
@click="handleConversationSidebarToggle"
/>
</div>
<Transition
enter-active-class="transition-transform duration-200 ease-in-out"
leave-active-class="transition-transform duration-200 ease-in-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
>
<div
v-if="isContactSidebarOpen"
id="contact-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
</div>
</Transition>
</div>
</section>
</template>
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
const props = defineProps({
selectedContact: {
@@ -108,8 +109,12 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
</script>
<template>
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<ContactSidebarSection
v-if="hasContactAttributes"
:title="t('CONTACTS_LAYOUT.SIDEBAR.TABS.ATTRIBUTES')"
body-class="p-0"
>
<div v-if="!hasNoUsedAttributes" class="flex flex-col px-4 py-1.5">
<ContactCustomAttributeItem
v-for="attribute in usedAttributes"
:key="attribute.id"
@@ -117,36 +122,43 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
:attribute="attribute"
/>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
<div class="flex-1 h-[1px] bg-n-slate-5" />
<span class="text-sm font-medium text-n-slate-10">{{
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
count: unusedAttributesCount,
})
}}</span>
<div class="flex-1 h-[1px] bg-n-slate-5" />
</div>
<div class="flex flex-col gap-3">
<div v-if="!hasNoUnusedAttributes" class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<div
v-if="!hasNoUnusedAttributes"
class="flex flex-col gap-3 px-4 py-4"
:class="{ 'border-t border-n-weak': !hasNoUsedAttributes }"
>
<span
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
>
{{
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
count: unusedAttributesCount,
})
}}
</span>
<div class="relative">
<span
class="absolute i-lucide-search size-3.5 top-2.5 left-3 text-n-slate-10"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border outline-none reset-base rounded-lg border-n-weak bg-n-alpha-black2 dark:bg-n-solid-2 text-n-slate-12"
/>
</div>
<div
v-if="filteredUnusedAttributes.length === 0 && !hasNoUnusedAttributes"
v-if="filteredUnusedAttributes.length === 0"
class="flex items-center justify-start h-11"
>
<p class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.NO_ATTRIBUTES') }}
</p>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex flex-col gap-2">
<div v-else class="flex flex-col">
<ContactCustomAttributeItem
v-for="attribute in filteredUnusedAttributes"
:key="attribute.id"
@@ -154,7 +166,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
/>
</div>
</div>
</div>
</ContactSidebarSection>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.EMPTY_STATE') }}
</p>
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
const { t } = useI18n();
const route = useRoute();
@@ -34,19 +35,24 @@ const contactConversations = computed(() =>
>
<Spinner />
</div>
<div
v-else-if="contactConversations.length > 0"
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in contactConversations"
:key="conversation.id"
:conversation="conversation"
:contact="contactsById(conversation.meta.sender.id)"
:state-inbox="stateInbox(conversation.inboxId)"
:account-labels="accountLabelsValue"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
/>
<div v-else-if="contactConversations.length > 0">
<ContactSidebarSection
:title="t('CONTACTS_LAYOUT.SIDEBAR.TABS.HISTORY')"
:count="contactConversations.length"
body-class="p-1.5 max-h-[60vh] overflow-y-auto"
>
<div class="flex flex-col">
<ConversationCard
v-for="conversation in contactConversations"
:key="conversation.id"
:conversation="conversation"
:contact="contactsById(conversation.meta.sender.id)"
:state-inbox="stateInbox(conversation.inboxId)"
:account-labels="accountLabelsValue"
class="border-0 rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
/>
</div>
</ContactSidebarSection>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.HISTORY.EMPTY_STATE') }}
@@ -12,9 +12,10 @@ import GalleryView from 'dashboard/components/widgets/conversation/components/Ga
import Media from 'dashboard/components-next/SharedAttachments/Media.vue';
import Files from 'dashboard/components-next/SharedAttachments/Files.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
const MEDIA_PEEK_LIMIT = 12;
const FILES_PEEK_LIMIT = 6;
const MEDIA_PEEK_LIMIT = 10;
const FILES_PEEK_LIMIT = 4;
const route = useRoute();
const router = useRouter();
@@ -73,17 +74,21 @@ onMounted(() => {
</script>
<template>
<div class="px-6">
<div>
<div v-if="isFetching" class="flex justify-center p-3">
<Spinner class="size-5" />
</div>
<p v-else-if="!hasContent" class="p-3 text-sm text-center text-n-slate-11">
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
</p>
<div v-else class="flex flex-col gap-5">
<ContactSidebarSection
v-else
body-class="flex flex-col gap-5 px-4 py-4 max-h-[60vh] overflow-y-auto"
>
<Media
:attachments="attachments"
:peek-limit="MEDIA_PEEK_LIMIT"
:columns="5"
show-jump-to-message
@select="onMediaSelect"
@jump-to-message="onJumpToMessage"
@@ -95,7 +100,7 @@ onMounted(() => {
@select="onFileSelect"
@jump-to-message="onJumpToMessage"
/>
</div>
</ContactSidebarSection>
<GalleryView
v-if="showGallery && selectedAttachment"
v-model:show="showGallery"
@@ -12,6 +12,7 @@ import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactMergeForm from 'dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue';
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
const props = defineProps({
selectedContact: {
@@ -103,43 +104,44 @@ const onMergeContacts = async () => {
</script>
<template>
<div class="flex flex-col gap-8 px-6">
<div class="flex flex-col gap-2">
<h4 class="text-base text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
</h4>
<div>
<ContactSidebarSection
:title="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE')"
body-class="flex flex-col gap-5 px-4 py-4"
>
<p class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.DESCRIPTION') }}
</p>
</div>
<ContactMergeForm
v-model:primary-contact-id="state.primaryContactId"
:selected-contact="selectedContact"
:primary-contact-list="primaryContactList"
:is-searching="isSearching"
:has-error="!!v$.primaryContactId.$error"
:error-message="
v$.primaryContactId.$error
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_REQUIRED_ERROR')
: ''
"
@search="onContactSearch"
/>
<div class="flex items-center justify-between gap-3">
<Button
variant="faded"
color="slate"
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="resetState"
<ContactMergeForm
v-model:primary-contact-id="state.primaryContactId"
:selected-contact="selectedContact"
:primary-contact-list="primaryContactList"
:is-searching="isSearching"
:has-error="!!v$.primaryContactId.$error"
:error-message="
v$.primaryContactId.$error
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_REQUIRED_ERROR')
: ''
"
@search="onContactSearch"
/>
<Button
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
class="w-full"
:is-loading="isMergingContact"
:disabled="isMergingContact"
@click="onMergeContacts"
/>
</div>
<div class="flex items-center justify-end gap-3">
<Button
variant="faded"
color="slate"
size="sm"
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="resetState"
/>
<Button
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
size="sm"
:is-loading="isMergingContact"
:disabled="isMergingContact"
@click="onMergeContacts"
/>
</div>
</ContactSidebarSection>
</div>
</template>
@@ -1,5 +1,5 @@
<script setup>
import { reactive, computed } from 'vue';
import { reactive, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
@@ -8,6 +8,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
import ContactNoteItem from './components/ContactNoteItem.vue';
const { t } = useI18n();
@@ -25,6 +26,16 @@ const isFetchingNotes = computed(() => uiFlags.value.isFetching);
const isCreatingNote = computed(() => uiFlags.value.isCreating);
const notes = computed(() => notesByContact.value(route.params.contactId));
const searchQuery = ref('');
const filteredNotes = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) return notes.value;
return notes.value.filter(note =>
(note.content || '').toLowerCase().includes(query)
);
});
const getWrittenBy = note => {
const isCurrentUser = note?.user?.id === currentUser.value.id;
return isCurrentUser
@@ -55,47 +66,73 @@ useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col gap-6">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
focus-on-mount
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 px-6"
>
<template #actions>
<div class="flex items-center gap-3">
<Button
variant="link"
color="blue"
size="sm"
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
class="hover:no-underline"
:is-loading="isCreatingNote"
:disabled="!state.message || isCreatingNote"
@click="onAdd(state.message)"
/>
</div>
</template>
</Editor>
<ContactSidebarSection
:title="t('CONTACTS_LAYOUT.PROFILE.SECTIONS.NOTES')"
:count="notes.length || null"
body-class="p-0"
>
<div class="border-b border-n-weak">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
class="[&>div]:!border-transparent [&>div]:!bg-transparent [&>div]:px-4 [&>div]:py-3"
>
<template #actions>
<div class="flex items-center gap-3">
<Button
variant="link"
color="blue"
size="sm"
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
class="hover:no-underline"
:is-loading="isCreatingNote"
:disabled="!state.message || isCreatingNote"
@click="onAdd(state.message)"
/>
</div>
</template>
</Editor>
</div>
<div
v-if="isFetchingNotes"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="notes.length > 0">
<ContactNoteItem
v-for="note in notes"
:key="note.id"
class="mx-6 py-4"
:note="note"
:written-by="getWrittenBy(note)"
allow-delete
@delete="onDelete"
/>
</div>
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
<template v-else-if="notes.length > 0">
<div class="px-4 py-3 border-b border-n-weak">
<div class="relative">
<span
class="absolute i-lucide-search size-3.5 top-2.5 left-3 text-n-slate-10"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SEARCH_PLACEHOLDER')"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border outline-none reset-base rounded-lg border-n-weak bg-n-alpha-black2 dark:bg-n-solid-2 text-n-slate-12"
/>
</div>
</div>
<div class="px-4 max-h-[60vh] overflow-y-auto [&>div]:!border-b-0">
<ContactNoteItem
v-for="note in filteredNotes"
:key="note.id"
class="py-3.5"
:note="note"
:written-by="getWrittenBy(note)"
allow-delete
@delete="onDelete"
/>
<p
v-if="filteredNotes.length === 0"
class="py-6 text-sm leading-6 text-center text-n-slate-11"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.NO_RESULTS') }}
</p>
</div>
</template>
<p v-else class="px-4 py-6 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EMPTY_STATE') }}
</p>
</div>
</ContactSidebarSection>
</template>
@@ -0,0 +1,43 @@
<script setup>
defineProps({
title: {
type: String,
default: '',
},
count: {
type: [Number, String],
default: null,
},
bodyClass: {
type: String,
default: 'px-4 py-4',
},
});
</script>
<template>
<section
class="flex flex-col overflow-hidden border rounded-2xl border-n-weak bg-n-solid-1"
>
<header
v-if="title || $slots.action"
class="flex items-center justify-between gap-2 px-5 py-4 border-b border-n-weak"
>
<h4
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
>
{{ title }}
<span
v-if="count !== null && count !== ''"
class="font-medium tracking-normal normal-case ms-1 text-n-slate-10"
>
{{ count }}
</span>
</h4>
<slot name="action" />
</header>
<div :class="bodyClass">
<slot />
</div>
</section>
</template>
@@ -12,6 +12,15 @@ import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/Contac
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import ContactStats from 'dashboard/components-next/Contacts/Pages/ContactStats.vue';
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
import ContactHistory from 'dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue';
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
import ContactMedia from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMedia.vue';
import ContactMerge from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue';
const props = defineProps({
selectedContact: {
type: Object,
@@ -38,6 +47,34 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const contactData = ref({});
const metaSeparator = '•';
const contactId = computed(() => props.selectedContact?.id);
const CONTACT_SECTIONS = [
{ value: 'details', label: 'CONTACTS_LAYOUT.PROFILE.SECTIONS.DETAILS' },
{ value: 'attributes', label: 'CONTACTS_LAYOUT.SIDEBAR.TABS.ATTRIBUTES' },
{ value: 'history', label: 'CONTACTS_LAYOUT.PROFILE.STATS.CONVERSATIONS' },
{ value: 'notes', label: 'CONTACTS_LAYOUT.PROFILE.STATS.NOTES' },
{ value: 'media', label: 'CONTACTS_LAYOUT.PROFILE.STATS.FILES' },
{ value: 'merge', label: 'CONTACTS_LAYOUT.SIDEBAR.TABS.MERGE' },
];
const activeSection = ref('details');
const sectionTabs = computed(() =>
CONTACT_SECTIONS.map(section => ({ label: t(section.label) }))
);
const activeSectionIndex = computed(() =>
CONTACT_SECTIONS.findIndex(section => section.value === activeSection.value)
);
const handleSectionChange = tab => {
const index = sectionTabs.value.findIndex(item => item.label === tab.label);
if (index !== -1) activeSection.value = CONTACT_SECTIONS[index].value;
};
const getInitialContactData = () => {
if (!props.selectedContact) return {};
return { ...props.selectedContact };
@@ -45,6 +82,9 @@ const getInitialContactData = () => {
onMounted(() => {
Object.assign(contactData.value, getInitialContactData());
if (contactId.value) {
store.dispatch('contacts/fetchAttachments', contactId.value);
}
});
const createdAt = computed(() => {
@@ -121,8 +161,8 @@ const handleAvatarDelete = async () => {
</script>
<template>
<div class="flex flex-col items-start gap-8 pb-6">
<div class="flex flex-col items-start gap-3">
<div class="flex flex-col gap-8 pb-10">
<div class="flex items-start gap-5 min-w-0">
<Avatar
:src="avatarSrc || ''"
:name="selectedContact?.name || ''"
@@ -131,73 +171,112 @@ const handleAvatarDelete = async () => {
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
<div class="flex flex-col gap-2 min-w-0">
<h1
class="text-xl font-semibold leading-tight tracking-tight truncate text-n-slate-12"
>
{{ selectedContact?.name }}
</h3>
<div class="flex flex-col gap-1.5">
</h1>
<div
class="flex flex-wrap items-center text-sm gap-x-2 gap-y-1 text-n-slate-11"
>
<span
v-if="selectedContact?.identifier"
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
class="inline-flex items-center gap-1"
>
<span class="i-ph-user-gear text-n-slate-10 size-4" />
{{ selectedContact?.identifier }}
</span>
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span
v-if="selectedContact?.identifier"
class="i-ph-activity text-n-slate-10 size-4"
/>
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
•
<span v-if="selectedContact?.identifier" class="text-n-slate-8">
{{ metaSeparator }}
</span>
<span>
{{ t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
</span>
<span class="text-n-slate-8">{{ metaSeparator }}</span>
<span>
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
</div>
<ContactLabels :contact-id="selectedContact?.id" />
</div>
<ContactLabels :contact-id="selectedContact?.id" />
</div>
<div class="flex flex-col items-start gap-6">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
is-details-view
@update="handleFormUpdate"
<ContactStats :contact-id="contactId" :last-seen="lastActivityAt" />
<div class="flex flex-col gap-4">
<TabBar
:tabs="sectionTabs"
:initial-active-tab="activeSectionIndex"
class="max-w-full bg-n-alpha-black2"
@tab-changed="handleSectionChange"
/>
<Button
:label="t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="updateContact"
/>
</div>
<Policy :permissions="['administrator']">
<div
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
<div>
<template v-if="activeSection === 'details'">
<div class="flex flex-col gap-4">
<ContactSidebarSection
:title="t('CONTACTS_LAYOUT.PROFILE.SECTIONS.DETAILS')"
body-class="px-4 py-4"
>
<div class="flex flex-col items-start gap-6">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
is-details-view
@update="handleFormUpdate"
/>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="updateContact"
/>
</div>
</ContactSidebarSection>
<Policy :permissions="['administrator']">
<ContactSidebarSection
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
body-class="flex flex-col items-start gap-4 p-4"
>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
size="sm"
@click="openConfirmDeleteContactDialog"
/>
</ContactSidebarSection>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</Policy>
</div>
</template>
<ContactCustomAttributes
v-else-if="activeSection === 'attributes'"
:selected-contact="selectedContact"
/>
<ContactHistory v-else-if="activeSection === 'history'" />
<ContactNotes v-else-if="activeSection === 'notes'" />
<ContactMedia v-else-if="activeSection === 'media'" />
<ContactMerge
v-else-if="activeSection === 'merge'"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</Policy>
</div>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
const props = defineProps({
contactId: {
type: [Number, String],
default: null,
},
lastSeen: {
type: String,
default: '',
},
});
const { t } = useI18n();
const conversationsByContact = useMapGetter(
'contactConversations/getAllConversationsByContactId'
);
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
const attachmentsByContact = useMapGetter('contacts/getContactAttachments');
const statTiles = computed(() => [
{
key: 'conversations',
label: t('CONTACTS_LAYOUT.PROFILE.STATS.CONVERSATIONS'),
value: conversationsByContact.value(props.contactId)?.length ?? 0,
},
{
key: 'notes',
label: t('CONTACTS_LAYOUT.PROFILE.STATS.NOTES'),
value: notesByContact.value(props.contactId)?.length ?? 0,
},
{
key: 'files',
label: t('CONTACTS_LAYOUT.PROFILE.STATS.FILES'),
value:
attachmentsByContact.value(props.contactId)?.filter(a => a.data_url)
?.length ?? 0,
},
]);
</script>
<template>
<div
class="grid grid-cols-2 overflow-hidden border sm:grid-cols-4 gap-px rounded-xl border-n-weak bg-n-weak"
>
<div
v-for="tile in statTiles"
:key="tile.key"
class="flex flex-col gap-2 px-5 py-5 bg-n-solid-1"
>
<span
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
>
{{ tile.label }}
</span>
<span class="text-3xl font-semibold tabular-nums text-n-slate-12">
{{ tile.value }}
</span>
</div>
<div class="flex flex-col gap-2 px-5 py-5 bg-n-solid-1">
<span
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
>
{{ t('CONTACTS_LAYOUT.PROFILE.STATS.LAST_SEEN') }}
</span>
<span class="text-base font-medium truncate text-n-slate-12">
{{ lastSeen || t('CONTACTS_LAYOUT.PROFILE.STATS.NEVER') }}
</span>
</div>
</div>
</template>
@@ -1,5 +1,4 @@
<script setup>
import { useI18n } from 'vue-i18n';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
defineProps({
@@ -8,21 +7,13 @@ defineProps({
default: () => {},
},
});
const { t } = useI18n();
</script>
<template>
<div :title="inbox.name" class="flex items-center gap-1 min-w-0">
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-body-main text-n-slate-11">
{{ inbox.name }}
</span>
<span
v-if="inbox.active === false"
class="inline-flex h-4 shrink-0 items-center rounded-md bg-n-alpha-2 px-1 text-label-mini font-medium text-n-slate-11"
>
{{ t('INBOX_MGMT.DISABLED') }}
</span>
</div>
</template>
@@ -234,7 +234,6 @@ onMounted(() => resetContacts());
ref="popoverRef"
:align="align"
:show-content-border="false"
:close-on-scroll="false"
@show="onPopoverShow"
@hide="onPopoverHide"
>
@@ -20,10 +20,21 @@ const props = defineProps({
attachments: { type: Array, default: () => [] },
peekLimit: { type: Number, default: 0 },
showJumpToMessage: { type: Boolean, default: false },
columns: { type: Number, default: 3 },
});
const emit = defineEmits(['select', 'jumpToMessage']);
const gridColsClass = computed(
() =>
({
3: 'grid-cols-3',
4: 'grid-cols-4',
5: 'grid-cols-5',
6: 'grid-cols-6',
})[props.columns] || 'grid-cols-3'
);
const { t } = useI18n();
const mediaAttachments = computed(() =>
@@ -177,7 +188,7 @@ const onDownloadFile = async attachment => {
@click="showAll = !showAll"
/>
</header>
<div class="grid grid-cols-3 gap-2">
<div class="grid gap-2" :class="gridColsClass">
<div
v-for="(attachment, index) in visibleMedia"
:key="attachment.id"
@@ -1,11 +1,7 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import {
useBreakpoints,
breakpointsTailwind,
useEventListener,
} from '@vueuse/core';
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
@@ -20,10 +16,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
closeOnScroll: {
type: Boolean,
default: true,
},
showContentBorder: {
type: Boolean,
default: true,
@@ -49,12 +41,8 @@ const { fixedPosition, updatePosition } = useDropdownPosition(
{ align: props.align }
);
const SCROLL_CLOSE_THRESHOLD = 24;
const triggerTopAtOpen = ref(0);
const show = async () => {
isActive.value = true;
triggerTopAtOpen.value = triggerRef.value?.getBoundingClientRect().top ?? 0;
if (!isMobile.value) {
await nextTick();
updatePosition();
@@ -68,22 +56,6 @@ const hide = () => {
emit('hide');
};
// The teleported popover tracks its trigger while ancestors scroll; allow
// small drift (trackpad inertia), but close once the trigger moves further.
useEventListener(
window,
'scroll',
event => {
if (!props.closeOnScroll || !showPopover.value) return;
if (popoverRef.value?.contains(event.target)) return;
const top = triggerRef.value?.getBoundingClientRect().top ?? 0;
if (Math.abs(top - triggerTopAtOpen.value) > SCROLL_CLOSE_THRESHOLD) {
hide();
}
},
{ capture: true, passive: true }
);
const toggle = async () => {
if (isActive.value) hide();
else await show();
@@ -1,5 +1,4 @@
<script setup>
import { useI18n } from 'vue-i18n';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
defineProps({
@@ -8,21 +7,13 @@ defineProps({
default: () => {},
},
});
const { t } = useI18n();
</script>
<template>
<div :title="inbox.name" class="flex items-center gap-1 min-w-0">
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-label-small text-n-slate-11">
{{ inbox.name }}
</span>
<span
v-if="inbox.active === false"
class="inline-flex h-4 shrink-0 items-center rounded-md bg-n-alpha-2 px-1 text-label-mini font-medium text-n-slate-11"
>
{{ t('INBOX_MGMT.DISABLED') }}
</span>
</div>
</template>
@@ -1,9 +1,9 @@
import { useMapGetter } from 'dashboard/composables/store';
import * as agentHelper from 'dashboard/helper/agentHelper';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAgentsList } from '../useAgentsList';
import { useMapGetter } from 'dashboard/composables/store';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
import * as agentHelper from 'dashboard/helper/agentHelper';
// Mock vue-i18n
vi.mock('vue-i18n', () => ({
@@ -94,32 +94,6 @@ describe('useAgentsList', () => {
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
});
it('keeps nameless agent bots and applies a fallback label', () => {
const namelessBot = {
id: 91,
name: null,
assignee_type: 'AgentBot',
availability_status: 'offline',
};
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => [
...allAgentsData,
namelessBot,
]),
});
const { agentsList } = useAgentsList();
// access the computed to trigger evaluation
expect(agentsList.value).toBeDefined();
const passedAgents =
agentHelper.getAgentsByUpdatedPresence.mock.calls[0][0];
expect(passedAgents).toContainEqual({
...namelessBot,
name: '-',
});
});
it('handles empty assignable agents', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
@@ -1,10 +1,10 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import {
getAgentsByUpdatedPresence,
getSortedAgentsByAvailability,
} from 'dashboard/helper/agentHelper';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
/**
* A composable function that provides a list of agents for assignment.
@@ -53,11 +53,7 @@ export function useAgentsList(
* @type {import('vue').ComputedRef<Array>}
*/
const agentsList = computed(() => {
const agents = (assignableAgents.value || []).map(agent =>
!agent.name && agent.assignee_type === 'AgentBot'
? { ...agent, name: '-' }
: agent
);
const agents = assignableAgents.value || [];
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
currentUser.value,
@@ -7,7 +7,7 @@
export const getAgentsByAvailability = (agents, availability) => {
return agents
.filter(agent => agent.availability_status === availability)
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
.sort((a, b) => a.name.localeCompare(b.name));
};
/**
@@ -26,18 +26,6 @@ describe('agentHelper', () => {
offlineAgentsData
);
});
it('does not throw when an agent has a null name', () => {
const agents = [
{ id: 1, name: null, availability_status: 'offline' },
{ id: 2, name: 'Zoe', availability_status: 'offline' },
];
expect(() => getAgentsByAvailability(agents, 'offline')).not.toThrow();
expect(
getAgentsByAvailability(agents, 'offline').map(agent => agent.id)
).toEqual([1, 2]);
});
});
describe('getSortedAgentsByAvailability', () => {
@@ -506,6 +506,19 @@
}
}
},
"PROFILE": {
"STATS": {
"CONVERSATIONS": "Conversations",
"NOTES": "Notes",
"FILES": "Files",
"LAST_SEEN": "Last seen",
"NEVER": "Never"
},
"SECTIONS": {
"DETAILS": "Details",
"NOTES": "Notes"
}
},
"SIDEBAR": {
"TABS": {
"ATTRIBUTES": "Attributes",
@@ -564,6 +577,8 @@
},
"NOTES": {
"PLACEHOLDER": "Add a note",
"SEARCH_PLACEHOLDER": "Search notes",
"NO_RESULTS": "No notes match your search",
"WROTE": "wrote",
"YOU": "You",
"SAVE": "Save note",
@@ -4,7 +4,6 @@
"DESCRIPTION": "A channel is the mode of communication your customer chooses to interact with you. An inbox is where you manage interactions for a specific channel. It can include communications from various sources such as email, live chat, and social media.",
"LEARN_MORE": "Learn more about inboxes",
"COUNT": "{n} inbox | {n} inboxes",
"DISABLED": "Disabled",
"SEARCH_PLACEHOLDER": "Search inboxes...",
"NO_RESULTS": "No inboxes found matching your search",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
@@ -1,5 +1,5 @@
<script setup>
import { onMounted, computed, ref } from 'vue';
import { onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
@@ -8,12 +8,6 @@ import { useRoute, useRouter } from 'vue-router';
import ContactsDetailsLayout from 'dashboard/components-next/Contacts/ContactsDetailsLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ContactDetails from 'dashboard/components-next/Contacts/Pages/ContactDetails.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
import ContactHistory from 'dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue';
import ContactMedia from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMedia.vue';
import ContactMerge from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue';
import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
const store = useStore();
const route = useRoute();
@@ -22,9 +16,6 @@ const router = useRouter();
const contact = useMapGetter('contacts/getContactById');
const uiFlags = useMapGetter('contacts/getUIFlags');
const activeTab = ref('attributes');
const contactMergeRef = ref(null);
const isFetchingItem = computed(() => uiFlags.value.isFetchingItem);
const isMergingContact = computed(() => uiFlags.value.isMerging);
const isUpdatingContact = computed(() => uiFlags.value.isUpdating);
@@ -37,25 +28,6 @@ const showSpinner = computed(
const { t } = useI18n();
const CONTACT_TABS_OPTIONS = [
{ key: 'ATTRIBUTES', value: 'attributes' },
{ key: 'HISTORY', value: 'history' },
{ key: 'NOTES', value: 'notes' },
{ key: 'MEDIA', value: 'media' },
{ key: 'MERGE', value: 'merge' },
];
const tabs = computed(() => {
return CONTACT_TABS_OPTIONS.map(tab => ({
label: t(`CONTACTS_LAYOUT.SIDEBAR.TABS.${tab.key}`),
value: tab.value,
}));
});
const activeTabIndex = computed(() => {
return CONTACT_TABS_OPTIONS.findIndex(v => v.value === activeTab.value);
});
const goToContactsList = () => {
if (window.history.state?.back || window.history.length > 1) {
router.back();
@@ -74,10 +46,6 @@ const fetchActiveContact = async () => {
}
};
const handleTabChange = tab => {
activeTab.value = tab.value;
};
const fetchContactNotes = () => {
const { contactId } = route.params;
if (contactId) store.dispatch('contactNotes/get', { contactId });
@@ -151,40 +119,6 @@ onMounted(() => {
:selected-contact="selectedContact"
@go-to-contacts-list="goToContactsList"
/>
<template #sidebarHeader>
<div class="px-6 pt-6 pb-3">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
class="w-full [&>button]:w-full bg-n-alpha-black2"
@tab-changed="handleTabChange"
/>
</div>
</template>
<template #sidebar>
<div
v-if="isFetchingItem"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<template v-else>
<ContactCustomAttributes
v-if="activeTab === 'attributes'"
:selected-contact="selectedContact"
/>
<ContactNotes v-if="activeTab === 'notes'" />
<ContactHistory v-if="activeTab === 'history'" />
<ContactMedia v-if="activeTab === 'media'" />
<ContactMerge
v-if="activeTab === 'merge'"
ref="contactMergeRef"
:selected-contact="selectedContact"
@go-to-contacts-list="goToContactsList"
@reset-tab="handleTabChange(CONTACT_TABS_OPTIONS[0])"
/>
</template>
</template>
</ContactsDetailsLayout>
</div>
</template>
@@ -135,7 +135,7 @@ onMounted(() => {
<BaseTableCell class="max-w-0">
<div class="flex items-center gap-4 min-w-0">
<Avatar
:name="bot.name || ''"
:name="bot.name"
:src="bot.thumbnail"
:size="40"
class="flex-shrink-0"
@@ -142,20 +142,10 @@ const openDelete = inbox => {
>
<ChannelIcon class="size-6 text-n-slate-10" :inbox="inbox" />
</div>
<div class="flex flex-col items-start gap-1 min-w-0">
<div class="flex items-center gap-1.5 min-w-0">
<span
class="block text-heading-3 text-n-slate-12 capitalize truncate"
>
{{ inbox.name }}
</span>
<span
v-if="inbox.active === false"
class="inline-flex h-5 shrink-0 items-center rounded-md bg-n-alpha-2 px-1.5 text-label-mini font-medium text-n-slate-11"
>
{{ $t('INBOX_MGMT.DISABLED') }}
</span>
</div>
<div class="flex flex-col items-start gap-1">
<span class="block text-heading-3 text-n-slate-12 capitalize">
{{ inbox.name }}
</span>
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
@@ -68,10 +68,6 @@ const isAgentBot = computed(
() => props.selectedItem?.assignee_type === 'AgentBot'
);
const selectedItemName = computed(() =>
!props.selectedItem?.name && isAgentBot.value ? '-' : props.selectedItem?.name
);
const selectedThumbnail = computed(
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
);
@@ -99,16 +95,16 @@ const selectedThumbnail = computed(
<h4
v-else
class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12"
:title="selectedItemName"
:title="selectedItem.name"
>
{{ selectedItemName }}
{{ selectedItem.name }}
</h4>
</div>
<Avatar
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
:src="selectedThumbnail"
:status="selectedItem.availability_status"
:name="selectedItemName"
:name="selectedItem.name"
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
:size="24"
hide-offline-status
@@ -53,9 +53,7 @@ export default {
computed: {
filteredOptions() {
return this.options.filter(option => {
return (option.name || '')
.toLowerCase()
.includes(this.search.toLowerCase());
return option.name.toLowerCase().includes(this.search.toLowerCase());
});
},
noResult() {
+12 -37
View File
@@ -11,8 +11,6 @@ import { IFrameHelper } from '../helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
import { emitter } from 'shared/helpers/mitt';
const TRANSCRIPT_COOLDOWN_MS = 15000;
export default {
components: {
ChatInputWrap,
@@ -26,9 +24,6 @@ export default {
data() {
return {
inReplyTo: null,
isSendingTranscript: false,
transcriptCooldown: false,
transcriptCooldownTimer: null,
};
},
computed: {
@@ -62,9 +57,6 @@ export default {
mounted() {
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
},
beforeUnmount() {
clearTimeout(this.transcriptCooldownTimer);
},
methods: {
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
...mapActions('conversationAttributes', ['getAttributes']),
@@ -98,35 +90,19 @@ export default {
toggleReplyTo(message) {
this.inReplyTo = message;
},
startTranscriptCooldown() {
this.transcriptCooldown = true;
clearTimeout(this.transcriptCooldownTimer);
this.transcriptCooldownTimer = setTimeout(() => {
this.transcriptCooldown = false;
}, TRANSCRIPT_COOLDOWN_MS);
},
async sendTranscript() {
if (
!this.hasEmail ||
this.isSendingTranscript ||
this.transcriptCooldown
) {
return;
}
this.isSendingTranscript = true;
try {
await sendEmailTranscript();
this.startTranscriptCooldown();
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
type: 'success',
});
} catch (error) {
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
});
} finally {
this.isSendingTranscript = false;
if (this.hasEmail) {
try {
await sendEmailTranscript();
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
type: 'success',
});
} catch (error) {
emitter.$emit(BUS_EVENTS.SHOW_ALERT, {
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
});
}
}
},
},
@@ -168,7 +144,6 @@ export default {
v-if="showEmailTranscriptButton"
type="clear"
class="font-normal"
:disabled="isSendingTranscript || transcriptCooldown"
@click="sendTranscript"
>
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
-1
View File
@@ -4,7 +4,6 @@ class ConversationReplyEmailJob < ApplicationJob
def perform(conversation_id, last_queued_id)
conversation = Conversation.find(conversation_id)
return unless conversation.account.active?
return unless conversation.inbox.active?
if conversation.messages.incoming&.last&.content_type == 'incoming_email'
ConversationReplyMailer.with(account: conversation.account).reply_without_summary(conversation, last_queued_id).deliver_later
@@ -13,7 +13,6 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
def should_fetch_emails?(inbox)
return false if inbox.account.suspended?
return false unless inbox.active?
return false unless inbox.channel.imap_enabled
return false if inbox.channel.reauthorization_required?
+1 -1
View File
@@ -25,7 +25,7 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
private
def should_fetch_email?(channel)
channel.inbox.active? && channel.imap_enabled? && !channel.reauthorization_required?
channel.imap_enabled? && !channel.reauthorization_required?
end
def process_email_for_channel(channel, interval)
-58
View File
@@ -1,8 +1,6 @@
class SendReplyJob < ApplicationJob
queue_as :high
INBOX_DISABLED_ERROR = 'This inbox is currently disabled'.freeze
CHANNEL_SERVICES = {
'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService,
'Channel::TwilioSms' => ::Twilio::SendOnTwilioService,
@@ -20,7 +18,6 @@ class SendReplyJob < ApplicationJob
def perform(message_id)
message = Message.find(message_id)
channel_name = message.conversation.inbox.channel.class.to_s
return handle_disabled_inbox(message, channel_name) unless message.inbox.active?
return send_on_facebook_page(message) if channel_name == 'Channel::FacebookPage'
@@ -32,61 +29,6 @@ class SendReplyJob < ApplicationJob
private
def handle_disabled_inbox(message, channel_name)
return unless send_service_available?(channel_name)
return unless deliverable_reply?(message, channel_name)
mark_message_failed(message)
end
def send_service_available?(channel_name)
channel_name == 'Channel::FacebookPage' || CHANNEL_SERVICES.key?(channel_name)
end
def deliverable_reply?(message, channel_name)
return false unless deliverable_message?(message)
return message.email_notifiable_message? if email_channel?(channel_name)
return email_notification_deliverable?(message, channel_name) if email_notification_channel?(channel_name)
true
end
def deliverable_message?(message)
return false if message.private?
return false unless message.outgoing? || message.template?
return false if message.source_id.present?
return false if message.content_type == 'voice_call'
true
end
def email_channel?(channel_name)
channel_name == 'Channel::Email'
end
def email_notification_channel?(channel_name)
%w[Channel::WebWidget Channel::Api].include?(channel_name)
end
def email_notification_deliverable?(message, channel_name)
return false unless message.email_notifiable_message?
return false if message.conversation.contact.email.blank?
return false unless message.account.within_email_rate_limit?
email_notification_enabled?(message.inbox, channel_name)
end
def email_notification_enabled?(inbox, channel_name)
return inbox.channel.continuity_via_email if channel_name == 'Channel::WebWidget'
return inbox.account.feature_enabled?('email_continuity_on_api_channel') if channel_name == 'Channel::Api'
false
end
def mark_message_failed(message)
Messages::StatusUpdateService.new(message, 'failed', INBOX_DISABLED_ERROR).perform
end
def send_on_facebook_page(message)
if message.conversation.additional_attributes['type'] == 'instagram_direct_message'
::Instagram::Messenger::SendOnInstagramService.new(message: message).perform
+4 -6
View File
@@ -57,11 +57,9 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
next if channel.blank?
event_name = event_name(messaging)
next if event_name.blank?
next unless channel.inbox.active? || event_name == :read
send(event_name, messaging, channel)
if (event_name = event_name(messaging))
send(event_name, messaging, channel)
end
end
end
@@ -128,7 +126,7 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
end
def event_name(messaging)
SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
@event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
end
def message(messaging, channel)
-1
View File
@@ -4,7 +4,6 @@ class Webhooks::LineEventsJob < ApplicationJob
def perform(params: {}, signature: '', post_body: '')
@params = params
return unless valid_event_payload?
return unless @channel.inbox.active?
return unless valid_post_body?(post_body, signature)
Line::IncomingMessageService.new(inbox: @channel.inbox, params: @params['line'].with_indifferent_access).perform
-5
View File
@@ -8,7 +8,6 @@ class Webhooks::SmsEventsJob < ApplicationJob
channel = Channel::Sms.find_by(phone_number: params[:to])
return unless channel
return if incoming_event?(params) && !channel.inbox.active?
process_event_params(channel, params)
end
@@ -26,8 +25,4 @@ class Webhooks::SmsEventsJob < ApplicationJob
def delivery_event?(params)
params[:type] == 'message-delivered' || params[:type] == 'message-failed'
end
def incoming_event?(params)
params[:type] == 'message-received'
end
end
+2 -7
View File
@@ -6,7 +6,7 @@ class Webhooks::TelegramEventsJob < ApplicationJob
channel = Channel::Telegram.find_by(bot_token: params[:bot_token])
if channel_is_inactive?(channel, params)
if channel_is_inactive?(channel)
log_inactive_channel(channel, params)
return
end
@@ -16,18 +16,13 @@ class Webhooks::TelegramEventsJob < ApplicationJob
private
def channel_is_inactive?(channel, params)
def channel_is_inactive?(channel)
return true if channel.blank?
return true unless channel.account.active?
return true unless channel.inbox.active? || update_message_event?(params)
false
end
def update_message_event?(params)
params.dig(:telegram, :edited_message).present? || params.dig(:telegram, :edited_business_message).present?
end
def log_inactive_channel(channel, params)
message = if channel&.id
"Account #{channel.account.id} is not active for channel #{channel.id}"
-1
View File
@@ -21,7 +21,6 @@ class Webhooks::TiktokEventsJob < MutexApplicationJob
def channel_is_inactive?
return true if channel.blank?
return true unless channel.account.active?
return true unless channel.inbox.active? || event_name == 'im_mark_read_msg'
false
end
+7 -21
View File
@@ -8,7 +8,7 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
def perform(params = {})
channel = find_channel_from_whatsapp_business_payload(params)
if channel_is_inactive?(channel, params)
if channel_is_inactive?(channel)
Rails.logger.warn("Inactive WhatsApp channel: #{channel&.phone_number || "unknown - #{params[:phone_number]}"}")
return
end
@@ -124,29 +124,15 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
].compact_blank.first
end
def channel_is_inactive?(channel, params)
def channel_is_inactive?(channel)
return true if channel.blank?
# Only skip for embedded signup when reauth is required; manual flow uses API keys and should still receive webhooks
return true if channel.reauthorization_required? && embedded_signup_channel?(channel)
return true unless channel.account.active?
return true unless channel.inbox.active? || existing_message_update_event?(params)
false
end
def status_update_event?(params)
value = params.dig(:entry, 0, :changes, 0, :value) || params
value[:statuses].present?
end
def existing_message_update_event?(params)
status_update_event?(params) || call_event?(params)
end
def call_event?(params)
params.dig(:entry, 0, :changes, 0, :field) == 'calls'
end
def embedded_signup_channel?(channel)
(channel.provider_config || {}).to_h['source'] == 'embedded_signup'
end
@@ -167,11 +153,11 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
end
def get_channel_from_wb_payload(wb_params)
metadata = wb_params[:entry].first[:changes].first.dig(:value, :metadata) || {}
Whatsapp::WebhookChannelFinderService.new(
display_phone_number: metadata[:display_phone_number],
phone_number_id: metadata[:phone_number_id]
).perform
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
# validate to ensure the phone number id matches the whatsapp channel
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
end
-2
View File
@@ -63,8 +63,6 @@ class AgentBotListener < BaseListener
private
def agent_bots_for(inbox, conversation = nil)
return [] unless inbox.active?
bots = []
bots << conversation.assignee_agent_bot if conversation&.assignee_agent_bot.present?
inbox_bot = active_inbox_agent_bot(inbox)
+2 -8
View File
@@ -18,7 +18,6 @@ class AutomationRuleListener < BaseListener
def message_created(event)
message = event.data[:message]
return unless message.inbox.active?
return if ignore_message_created_event?(event)
account = message.try(:account)
@@ -40,11 +39,10 @@ class AutomationRuleListener < BaseListener
def process_conversation_event(event, event_name)
return if performed_by_automation?(event)
return if auto_reply_event?(event, event_name)
auto_reply_skip_events = %w[conversation_created conversation_opened]
return if auto_reply_skip_events.include?(event_name) && ignore_auto_reply_event?(event)
conversation = event.data[:conversation]
return unless conversation.inbox.active?
account = conversation.account
changed_attributes = event.data[:changed_attributes]
@@ -76,10 +74,6 @@ class AutomationRuleListener < BaseListener
event.data[:performed_by].present? && event.data[:performed_by].instance_of?(AutomationRule)
end
def auto_reply_event?(event, event_name)
%w[conversation_created conversation_opened].include?(event_name) && ignore_auto_reply_event?(event)
end
def ignore_auto_reply_event?(event)
conversation = event.data[:conversation]
conversation.additional_attributes['auto_reply'].present?
-1
View File
@@ -3,7 +3,6 @@ class CsatSurveyListener < BaseListener
conversation = extract_conversation_and_account(event)[0]
return unless conversation.resolved?
return unless conversation.inbox.active?
CsatSurveyService.new(conversation: conversation).perform
end
-1
View File
@@ -6,7 +6,6 @@ class ReplyMailbox < ApplicationMailbox
def process
# Return early if no conversation was found (e.g., notification emails, suspended accounts)
return unless @conversation
return unless @conversation.inbox.active?
# Wrap everything in a transaction to ensure atomicity
# This prevents orphan conversations if message/attachment creation fails
-1
View File
@@ -58,7 +58,6 @@ class Campaign < ApplicationRecord
def trigger!
return unless one_off?
return unless feature_enabled?
return unless inbox.active?
return unless mark_processing!
execute_campaign
@@ -7,8 +7,6 @@ class Line::IncomingMessageService
LINE_STICKER_IMAGE_URL = 'https://stickershop.line-scdn.net/stickershop/v1/sticker/%s/android/sticker.png'.freeze
def perform
return unless @inbox.active?
# probably test events
return if params[:events].blank?
@@ -22,7 +22,6 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
# The actual persistence happens in ReplyMailbox within a transaction that includes message creation.
def find
return nil unless @channel # No valid channel found
return nil unless @inbox.active?
return nil unless incoming_email_from_valid_email? # Skip edge cases
# Check if conversation already exists by in_reply_to
@@ -4,8 +4,6 @@ class Sms::IncomingMessageService
pattr_initialize [:inbox!, :params!]
def perform
return unless @inbox.active?
set_contact
set_conversation
@message = @conversation.messages.create!(
@@ -7,8 +7,6 @@ class Telegram::IncomingMessageService
pattr_initialize [:inbox!, :params!]
def perform
return unless @inbox.active?
# chatwoot doesn't support group conversations at the moment
transform_business_message!
return unless private_message?
-2
View File
@@ -4,8 +4,6 @@ class Tiktok::MessageService
pattr_initialize [:channel!, :content!, :outgoing_echo]
def perform
return unless channel.inbox.active?
if outgoing_message?
message = find_message(tt_conversation_id, tt_message_id)
return if message.present?
@@ -7,7 +7,6 @@ class Twilio::IncomingMessageService
def perform
return if twilio_channel.blank?
return unless inbox.active?
set_contact
set_conversation
@@ -5,8 +5,6 @@ class Twitter::DirectMessageParserService < Twitter::WebhooksBaseService
return if source_app_id == parent_app_id
set_inbox
return unless @inbox.active?
ensure_contacts
set_conversation
@message = @conversation.messages.create!(
@@ -4,7 +4,6 @@ class Twitter::TweetParserService < Twitter::WebhooksBaseService
def perform
set_inbox
return unless @inbox.active?
return if !tweets_enabled? || message_already_exist? || user_has_blocked?
create_message
@@ -13,8 +13,6 @@ class Whatsapp::IncomingMessageBaseService
if processed_params.try(:[], :statuses).present?
process_statuses
elsif messages_data.present?
return unless @inbox.active?
process_messages
end
end
@@ -6,10 +6,12 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
end
def perform_reply
return send_template_message if template_params.present?
return send_session_message if message.conversation.can_reply?
message.update!(status: :failed, external_error: I18n.t('errors.whatsapp.message_outside_messaging_window'))
should_send_template_message = template_params.present? || !message.conversation.can_reply?
if should_send_template_message
send_template_message
else
send_session_message
end
end
def send_template_message
@@ -1,35 +0,0 @@
# Resolves the WhatsApp channel for an inbound WhatsApp Cloud webhook. Meta's
# display_phone_number can arrive formatted or in a country-specific variant (e.g. Brazil
# omits the mobile 9, Argentina adds a digit after the country code), so we try the
# raw digits first and then a normalized fallback, accepting only a candidate whose
# phone_number_id matches.
class Whatsapp::WebhookChannelFinderService
def initialize(display_phone_number:, phone_number_id:)
@display_phone_number = display_phone_number
@phone_number_id = phone_number_id
end
def perform
return if digits.blank?
candidates = [
Channel::Whatsapp.find_by(phone_number: "+#{digits}"),
channel_by_normalized_number
]
candidates.compact.find { |channel| channel.provider_config['phone_number_id'] == @phone_number_id }
end
private
def digits
@digits ||= @display_phone_number.to_s.gsub(/[^0-9]/, '')
end
def channel_by_normalized_number
normalizer = Whatsapp::PhoneNumberNormalizationService::NORMALIZERS
.lazy.map(&:new).find { |n| n.handles_country?(digits) }
return unless normalizer
Channel::Whatsapp.find_by(phone_number: "+#{normalizer.normalize(digits)}")
end
end
@@ -2,7 +2,6 @@ json.id resource.id
json.avatar_url resource.try(:avatar_url)
json.channel_id resource.channel_id
json.name resource.name
json.active resource.active
json.channel_type resource.channel_type
json.greeting_enabled resource.greeting_enabled
json.greeting_message resource.greeting_message
-1
View File
@@ -154,7 +154,6 @@ en:
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
message_outside_messaging_window: 'Message not sent because the WhatsApp 24-hour customer service window is closed and no template parameters were provided. Send an approved template message instead.'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
-6
View File
@@ -581,12 +581,6 @@ Rails.application.routes.draw do
end
end
resources :email_channel_migrations, only: [:create]
resources :inboxes, only: [] do
member do
post :disable
post :enable
end
end
end
end
end
@@ -1,7 +0,0 @@
# frozen_string_literal: true
class AddActiveToInboxes < ActiveRecord::Migration[7.0]
def change
add_column :inboxes, :active, :boolean, default: true, null: false
end
end
-1
View File
@@ -1048,7 +1048,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.boolean "enable_email_collect", default: true
t.boolean "csat_survey_enabled", default: false
t.boolean "allow_messages_after_resolved", default: true
t.boolean "active", default: true, null: false
t.jsonb "auto_assignment_config", default: {}
t.boolean "lock_to_single_conversation", default: false, null: false
t.bigint "portal_id"
@@ -1,7 +1,6 @@
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
before_action :set_call, only: %i[show accept reject terminate upload_recording]
before_action :set_call_context, only: :initiate
before_action :ensure_inbox_active, only: :initiate
before_action :ensure_calling_enabled, only: :initiate
before_action :ensure_sdp_offer, only: :initiate
before_action :ensure_contact_phone, only: :initiate
@@ -97,10 +96,6 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled'))
end
def ensure_inbox_active
render_inbox_disabled_error unless @inbox.active?
end
def ensure_sdp_offer
return if params[:sdp_offer].present?
@@ -1,8 +1,6 @@
module Enterprise::Api::V1::Accounts::AgentsController
def create
super
return if @agent.blank?
associate_agent_with_custom_role
end
@@ -90,10 +90,10 @@ class Twilio::VoiceController < ApplicationController
from_number.start_with?('client:')
end
# A fresh contact-initiated leg on an inbox that cannot receive calls.
# A fresh contact-initiated leg on an inbox with inbound calls turned off.
# Reject it so no conference, conversation, or Call row is created.
def reject_inbound?
twilio_direction == 'inbound' && !agent_leg?(twilio_from) && (!inbox.active? || !inbox.channel.inbound_calls_enabled?)
twilio_direction == 'inbound' && !agent_leg?(twilio_from) && !inbox.channel.inbound_calls_enabled?
end
def reject_twiml
@@ -16,7 +16,6 @@ class Voice::OutboundCallBuilder
def perform!
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
raise ArgumentError, 'Agent required' if user.blank?
raise CustomExceptions::InboxDisabled unless inbox.active?
# Claim for the caller if a reused conversation is unassigned at trigger time; wins over auto-assignment.
# New conversations set the assignee at creation instead (see create_conversation!).
@@ -75,7 +75,7 @@ class Whatsapp::IncomingCallService
end
def create_inbound_call(payload)
unless inbox.active? && inbox.channel.inbound_calls_enabled?
unless inbox.channel.inbound_calls_enabled?
Rails.logger.info "[WHATSAPP CALL] Inbound calls disabled for inbox #{inbox.id}; rejecting call #{payload[:id]}"
inbox.channel.provider_service.reject_call(payload[:id])
return
-19
View File
@@ -1,19 +0,0 @@
# frozen_string_literal: true
class CustomExceptions::InboxDisabled < CustomExceptions::Base
def initialize(data = {})
super
end
def message
'This inbox is currently disabled'
end
def error_code
'inbox_disabled'
end
def http_status
:forbidden
end
end
-18
View File
@@ -23,12 +23,6 @@ RSpec.describe AgentBuilder, type: :model do
end
describe '#perform' do
it 'locks the account while checking and creating the agent' do
expect(account).to receive(:with_lock).and_call_original
agent_builder.perform
end
context 'when user does not exist' do
it 'creates a new user' do
expect { agent_builder.perform }.to change(User, :count).by(1)
@@ -73,17 +67,5 @@ RSpec.describe AgentBuilder, type: :model do
expect(user.encrypted_password).not_to be_empty
end
end
context 'when the account has reached its agent limit' do
before do
allow(account).to receive(:usage_limits).and_return({ agents: account.account_users.count })
end
it 'raises a limit exceeded error without creating a user' do
expect { agent_builder.perform }.to raise_error(described_class::LimitExceededError, described_class::LIMIT_EXCEEDED_MESSAGE)
expect(User.from_email(email)).to be_nil
end
end
end
end
@@ -13,9 +13,7 @@ RSpec.describe 'Linear Integration API', type: :request do
end
describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'deletes the linear integration when the user is an administrator' do
it 'deletes the linear integration' do
# Stub the HTTP call to Linear's revoke endpoint
allow(HTTParty).to receive(:post).with(
'https://api.linear.app/oauth/revoke',
@@ -23,19 +21,11 @@ RSpec.describe 'Linear Integration API', type: :request do
).and_return(instance_double(HTTParty::Response, success?: true))
delete "/api/v1/accounts/#{account.id}/integrations/linear",
headers: admin.create_new_auth_token,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(account.hooks.count).to eq(0)
end
it 'returns unauthorized for an agent and keeps the integration' do
delete "/api/v1/accounts/#{account.id}/integrations/linear",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
expect(account.hooks.count).to eq(1)
end
end
describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do
@@ -159,33 +159,19 @@ RSpec.describe 'Shopify Integration API', type: :request do
end
describe 'DELETE /api/v1/accounts/:account_id/integrations/shopify' do
let(:admin) { create(:user, account: account, role: :administrator) }
before do
create(:integrations_hook, :shopify, account: account)
end
context 'when it is an administrator' do
context 'when it is an authenticated user' do
it 'deletes the shopify integration' do
expect do
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
headers: admin.create_new_auth_token,
as: :json
end.to change { account.hooks.count }.by(-1)
expect(response).to have_http_status(:ok)
end
end
context 'when it is an agent' do
it 'returns unauthorized and keeps the integration' do
expect do
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
headers: agent.create_new_auth_token,
as: :json
end.not_to(change { account.hooks.count })
end.to change { account.hooks.count }.by(-1)
expect(response).to have_http_status(:unauthorized)
expect(response).to have_http_status(:ok)
end
end
@@ -31,18 +31,6 @@ RSpec.describe '/api/v1/widget/config', type: :request do
response_data = response.parsed_body
expect(response_data.keys).to include(*response_keys)
end
it 'does not initialize config or create a contact when the inbox is disabled' do
web_widget.inbox.update!(active: false)
expect do
post '/api/v1/widget/config',
params: params,
as: :json
end.not_to change(Contact, :count)
expect(response).to have_http_status(:forbidden)
end
end
context 'with correct website token and valid X-Auth-Token' do
@@ -60,17 +48,6 @@ RSpec.describe '/api/v1/widget/config', type: :request do
expect(response_data['contact']['pubsub_token']).to eq(contact_inbox.pubsub_token)
end
it 'does not initialize config when the inbox is disabled' do
web_widget.inbox.update!(active: false)
post '/api/v1/widget/config',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
end
it 'returns 401 if account is suspended' do
account.update!(status: :suspended)
@@ -37,18 +37,6 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
expect(ContactIdentifyAction).to have_received(:new).with(expected_params)
expect(identify_action).to have_received(:perform)
end
it 'does not update the contact when the inbox is disabled' do
web_widget.inbox.update!(active: false)
patch '/api/v1/widget/contact',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(ContactIdentifyAction).not_to have_received(:new)
end
end
context 'with update contact' do
@@ -267,18 +255,6 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
expect(response).to have_http_status(:unauthorized)
end
it 'does not set user when the inbox is disabled' do
web_widget.inbox.update!(active: false)
patch '/api/v1/widget/contact/set_user',
params: params.merge(identifier_hash: correct_identifier_hash),
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(ContactIdentifyAction).not_to have_received(:new)
end
end
end
@@ -287,32 +263,19 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
context 'with invalid website token' do
it 'returns unauthorized' do
post '/api/v1/widget/contact/destroy_custom_attributes', params: { website_token: '' }
post '/api/v1/widget/destroy_custom_attributes', params: { website_token: '' }
expect(response).to have_http_status(:not_found)
end
end
context 'with correct website token' do
it 'calls destroy custom attributes' do
post '/api/v1/widget/contact/destroy_custom_attributes',
post '/api/v1/widget/destroy_custom_attributes',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(contact.reload.custom_attributes).to eq({})
end
it 'does not destroy custom attributes when the inbox is disabled' do
contact.update!(custom_attributes: { 'test' => 'value' })
web_widget.inbox.update!(active: false)
post '/api/v1/widget/contact/destroy_custom_attributes',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(contact.reload.custom_attributes).to eq('test' => 'value')
end
end
end
end
@@ -43,18 +43,6 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
expect(json_response['id']).to eq(conversation.display_id)
expect(json_response['status']).to eq(conversation.status)
end
it 'returns the conversation when the inbox is disabled' do
web_widget.inbox.update!(active: false)
get '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['id']).to eq(conversation.display_id)
end
end
context 'with a conversation but invalid source id' do
@@ -74,19 +62,6 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
end
describe 'POST /api/v1/widget/conversations' do
it 'does not create a conversation when the inbox is disabled' do
web_widget.inbox.update!(active: false)
expect do
post '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },
params: conversation_params,
as: :json
end.not_to change(Conversation, :count)
expect(response).to have_http_status(:forbidden)
end
it 'creates a conversation with correct details' do
post '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },
@@ -269,40 +244,11 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
expect(conversation.reload.contact_last_seen_at).not_to be_nil
end
it 'updates last seen when the inbox is disabled' do
current_time = DateTime.now.utc
allow(DateTime).to receive(:now).and_return(current_time)
web_widget.inbox.update!(active: false)
expect(Conversations::UpdateMessageStatusJob).to receive(:perform_later).with(conversation.id, current_time)
post '/api/v1/widget/conversations/update_last_seen',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.contact_last_seen_at).not_to be_nil
end
end
end
describe 'POST /api/v1/widget/conversations/transcript' do
context 'with a conversation' do
it 'does not send transcript email when the inbox is disabled' do
web_widget.inbox.update!(active: false)
contact.update!(email: 'test@test.com')
expect(ConversationReplyMailer).not_to receive(:with)
post '/api/v1/widget/conversations/transcript',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:forbidden)
end
it 'sends transcript email' do
contact.update(email: 'test@test.com')
mailer = double

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