Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c5077dbad | ||
|
|
4bd84f05be |
+1
-1
@@ -1 +1 @@
|
||||
4.14.1
|
||||
4.14.0
|
||||
|
||||
@@ -92,18 +92,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
def fallback_params(attachment)
|
||||
{
|
||||
fallback_title: attachment['title'],
|
||||
external_url: attachment['url'] || attachment.dig('payload', 'url')
|
||||
external_url: attachment['url']
|
||||
}
|
||||
end
|
||||
|
||||
# Facebook shared posts point to page URLs, not downloadable media URLs.
|
||||
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
|
||||
def normalize_file_type(type)
|
||||
return :fallback if type.to_sym == :share
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
|
||||
def update
|
||||
@account = Current.account
|
||||
finalize = finalizing_account_details?
|
||||
|
||||
@account.assign_attributes(account_params)
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
@account.custom_attributes.delete('onboarding_step') if finalize
|
||||
@account.save!
|
||||
|
||||
# TODO: re-enable when the help center generation UI is ready to surface progress
|
||||
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
|
||||
|
||||
render 'api/v1/accounts/update', format: :json
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def finalizing_account_details?
|
||||
@account.custom_attributes['onboarding_step'] == 'account_details'
|
||||
end
|
||||
|
||||
def website
|
||||
custom_attributes_params[:website]
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.permit(:name, :locale)
|
||||
end
|
||||
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
|
||||
end
|
||||
end
|
||||
@@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
@account.settings.merge!(settings_params)
|
||||
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
|
||||
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
|
||||
@account.save!
|
||||
end
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
class Api::V1::Profile::SessionsController < Api::BaseController
|
||||
before_action :set_session, only: [:destroy]
|
||||
|
||||
def index
|
||||
@sessions = current_user.user_sessions.order(last_activity_at: :desc)
|
||||
@current_client_id = request.headers['client']
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @session.current?(request.headers['client'])
|
||||
render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
revoke_token!(@session.client_id)
|
||||
@session.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_session
|
||||
@session = current_user.user_sessions.find(params[:id])
|
||||
end
|
||||
|
||||
def revoke_token!(client_id)
|
||||
tokens = current_user.tokens
|
||||
tokens.delete(client_id)
|
||||
current_user.update!(tokens: tokens)
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,6 @@ class ApplicationController < ActionController::Base
|
||||
include RequestExceptionHandler
|
||||
include Pundit::Authorization
|
||||
include SwitchLocale
|
||||
include TrackSessionActivity
|
||||
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
module TrackSessionActivity
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_action :update_session_activity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_session_activity
|
||||
return unless current_user
|
||||
return if request.headers['client'].blank?
|
||||
|
||||
UserSessionTrackingService.new(
|
||||
user: current_user,
|
||||
request: request,
|
||||
client_id: request.headers['client']
|
||||
).update_activity!
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Session activity update failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,4 @@
|
||||
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
MAX_SESSIONS = 5
|
||||
|
||||
# Prevent session parameter from being passed
|
||||
# Unpermitted parameter: session
|
||||
wrap_parameters format: []
|
||||
@@ -16,14 +14,12 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
|
||||
user = find_user_for_authentication
|
||||
return handle_mfa_required(user) if user&.mfa_enabled?
|
||||
return if user && enforce_session_limit_for_password_login(user)
|
||||
|
||||
# Only proceed with standard authentication if no MFA is required
|
||||
super
|
||||
end
|
||||
|
||||
def render_create_success
|
||||
track_user_session
|
||||
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
|
||||
end
|
||||
|
||||
@@ -57,8 +53,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
end
|
||||
|
||||
def handle_sso_authentication
|
||||
return if enforce_session_limit_for_password_login(@resource)
|
||||
|
||||
authenticate_resource_with_sso_token
|
||||
yield @resource if block_given?
|
||||
render_create_success
|
||||
@@ -109,7 +103,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
end
|
||||
|
||||
def sign_in_mfa_user(user)
|
||||
evict_oldest_session(user) if sessions_limit_reached?(user)
|
||||
@resource = user
|
||||
@token = @resource.create_token
|
||||
@resource.save!
|
||||
@@ -121,115 +114,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
def render_mfa_error(message_key, status = :bad_request)
|
||||
render json: { error: I18n.t(message_key) }, status: status
|
||||
end
|
||||
|
||||
def sessions_limit_reached?(user)
|
||||
(user.tokens || {}).keys.size >= MAX_SESSIONS
|
||||
end
|
||||
|
||||
# Returns true when the response has been rendered (e.g., 409 picker shown). Browsers see
|
||||
# the picker; non-browser clients (mobile, API) auto-evict so they don't get stuck on a UI
|
||||
# they can't render. If the user is revoking, perform the revoke and let login proceed.
|
||||
def enforce_session_limit_for_password_login(user)
|
||||
if revoking_sessions?
|
||||
revoke_sessions_for_login(user)
|
||||
return false
|
||||
end
|
||||
|
||||
return false unless sessions_limit_reached?(user)
|
||||
|
||||
# Picker only when every token has a tracked session; partial tracking would
|
||||
# show a misleading count, so fall through to silent eviction instead.
|
||||
if browser_request? && user.user_sessions.count >= user.tokens.size
|
||||
handle_sessions_limit_for_login(user)
|
||||
true
|
||||
else
|
||||
evict_oldest_session(user)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def browser_request?
|
||||
request.user_agent.to_s.include?('Mozilla')
|
||||
end
|
||||
|
||||
def revoking_sessions?
|
||||
params[:revoke_session_id].present? || params[:revoke_all_sessions].present?
|
||||
end
|
||||
|
||||
def revoke_sessions_for_login(user)
|
||||
if params[:revoke_all_sessions].present?
|
||||
user.tokens = {}
|
||||
user.save!
|
||||
user.user_sessions.destroy_all
|
||||
elsif params[:revoke_session_id].present?
|
||||
session = user.user_sessions.find_by(id: params[:revoke_session_id])
|
||||
return unless session
|
||||
|
||||
user.tokens.delete(session.client_id)
|
||||
user.save!
|
||||
session.destroy!
|
||||
end
|
||||
end
|
||||
|
||||
def evict_oldest_session(user)
|
||||
# Untracked tokens are pre-rollout leftovers and almost always older than any
|
||||
# tracked session; drop those first so freshly tracked logins aren't evicted.
|
||||
return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size
|
||||
|
||||
oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first
|
||||
return evict_oldest_token(user) unless oldest_session
|
||||
|
||||
user.tokens.delete(oldest_session.client_id)
|
||||
user.save!
|
||||
oldest_session.destroy!
|
||||
end
|
||||
|
||||
# Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped).
|
||||
def evict_oldest_token(user)
|
||||
return if user.tokens.blank?
|
||||
|
||||
oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first
|
||||
return unless oldest_client_id
|
||||
|
||||
user.tokens.delete(oldest_client_id)
|
||||
user.save!
|
||||
end
|
||||
|
||||
def handle_sessions_limit_for_login(user)
|
||||
sessions = user.user_sessions.order(last_activity_at: :desc).map do |session|
|
||||
{
|
||||
id: session.id,
|
||||
browser_name: session.browser_name,
|
||||
browser_version: session.browser_version,
|
||||
device_name: session.device_name,
|
||||
platform_name: session.platform_name,
|
||||
platform_version: session.platform_version,
|
||||
ip_address: session.ip_address,
|
||||
city: session.city,
|
||||
country: session.country,
|
||||
last_activity_at: session.last_activity_at,
|
||||
created_at: session.created_at
|
||||
}
|
||||
end
|
||||
|
||||
render json: {
|
||||
sessions_limit_reached: true,
|
||||
sessions: sessions
|
||||
}, status: :conflict
|
||||
end
|
||||
|
||||
def track_user_session
|
||||
client_id = @token&.try(:client) || response.headers['client']
|
||||
return unless client_id.present? && @resource.present?
|
||||
|
||||
UserSessionTrackingService.new(
|
||||
user: @resource,
|
||||
request: request,
|
||||
client_id: client_id
|
||||
).create_or_update!
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Session tracking failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
|
||||
|
||||
@@ -106,10 +106,4 @@ export default {
|
||||
const urlData = endPoints('resetAccessToken');
|
||||
return axios.post(urlData.url);
|
||||
},
|
||||
getSessions() {
|
||||
return axios.get('/api/v1/profile/sessions');
|
||||
},
|
||||
revokeSession(id) {
|
||||
return axios.delete(`/api/v1/profile/sessions/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class OnboardingAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('onboarding', { accountScoped: true });
|
||||
}
|
||||
|
||||
update(data) {
|
||||
return axios.patch(this.url, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new OnboardingAPI();
|
||||
@@ -49,7 +49,6 @@ const emit = defineEmits(['edit', 'delete']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
const STATUS_PROCESSING = 'processing';
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
@@ -69,15 +68,9 @@ const campaignStatus = computed(() => {
|
||||
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
|
||||
}
|
||||
|
||||
if (props.status === STATUS_COMPLETED) {
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED');
|
||||
}
|
||||
|
||||
if (props.status === STATUS_PROCESSING) {
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING');
|
||||
}
|
||||
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
||||
return props.status === STATUS_COMPLETED
|
||||
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
|
||||
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
||||
});
|
||||
|
||||
const inboxName = computed(() => props.inbox?.name || '');
|
||||
|
||||
+15
-29
@@ -1,48 +1,34 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
const emit = defineEmits(['add', 'import', 'export']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const contactMenuItems = computed(() => [
|
||||
const contactMenuItems = [
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
|
||||
action: 'add',
|
||||
value: 'add',
|
||||
icon: 'i-lucide-plus',
|
||||
},
|
||||
...(checkPermissions(['administrator', 'contact_manage'])
|
||||
? [
|
||||
{
|
||||
label: t(
|
||||
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'
|
||||
),
|
||||
action: 'export',
|
||||
value: 'export',
|
||||
icon: 'i-lucide-upload',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(checkPermissions(['administrator', 'contact_manage'])
|
||||
? [
|
||||
{
|
||||
label: t(
|
||||
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'
|
||||
),
|
||||
action: 'import',
|
||||
value: 'import',
|
||||
icon: 'i-lucide-download',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
|
||||
action: 'export',
|
||||
value: 'export',
|
||||
icon: 'i-lucide-upload',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
|
||||
action: 'import',
|
||||
value: 'import',
|
||||
icon: 'i-lucide-download',
|
||||
},
|
||||
];
|
||||
const showActionsDropdown = ref(false);
|
||||
|
||||
const handleContactAction = ({ action }) => {
|
||||
|
||||
@@ -35,14 +35,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits([
|
||||
'accept',
|
||||
'reject',
|
||||
'end',
|
||||
'toggleMute',
|
||||
'goToConversation',
|
||||
'dismiss',
|
||||
]);
|
||||
defineEmits(['accept', 'reject', 'end', 'toggleMute', 'goToConversation']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -122,19 +115,6 @@ const channelIcon = computed(() => {
|
||||
<span class="text-xs font-medium text-n-teal-9 tracking-tight">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
<!-- Dismiss: removes the notification from the UI without declining.
|
||||
Incoming only — outgoing/ongoing calls are ended via the call
|
||||
controls, not silently dismissed. -->
|
||||
<NextButton
|
||||
v-if="isIncoming"
|
||||
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.DISMISS_CALL')"
|
||||
icon="i-ph-x-bold"
|
||||
slate
|
||||
ghost
|
||||
xs
|
||||
class="!rounded-full -my-1"
|
||||
@click="$emit('dismiss')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ const {
|
||||
joinCall,
|
||||
endCall: endCallSession,
|
||||
rejectIncomingCall,
|
||||
dismissCall,
|
||||
formattedCallDuration,
|
||||
} = useCallSession();
|
||||
|
||||
@@ -52,15 +51,6 @@ const mainCardState = computed(() => {
|
||||
: VOICE_CALL_DIRECTION.INCOMING;
|
||||
});
|
||||
|
||||
// Stacked cards are always non-active (ringing) calls, so reflect each call's
|
||||
// real direction. An outbound call must render as OUTGOING — otherwise it shows
|
||||
// the incoming-only dismiss (✕) control and the agent could drop it locally
|
||||
// without terminating, leaving the customer ringing with no widget to end it.
|
||||
const stackedCardState = call =>
|
||||
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
? VOICE_CALL_DIRECTION.OUTGOING
|
||||
: VOICE_CALL_DIRECTION.INCOMING;
|
||||
|
||||
const toggleMute = () => {
|
||||
isMuted.value = !isMuted.value;
|
||||
setWhatsappCallMuted(isMuted.value);
|
||||
@@ -240,11 +230,10 @@ onBeforeUnmount(stopRingtone);
|
||||
v-for="call in stackedIncomingCalls"
|
||||
:key="call.callSid"
|
||||
:call="call"
|
||||
:state="stackedCardState(call)"
|
||||
:state="VOICE_CALL_DIRECTION.INCOMING"
|
||||
:call-info="getCallInfo(call)"
|
||||
@accept="handleJoinCall(call)"
|
||||
@reject="rejectIncomingCall(call.callSid)"
|
||||
@dismiss="dismissCall(call.callSid)"
|
||||
@go-to-conversation="goToConversation(call)"
|
||||
/>
|
||||
|
||||
@@ -259,7 +248,6 @@ onBeforeUnmount(stopRingtone);
|
||||
:show-mute="hasActiveCall && isWhatsappActive"
|
||||
@accept="handleJoinCall(primaryIncomingCall)"
|
||||
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
|
||||
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
|
||||
@end="handleEndCall"
|
||||
@toggle-mute="toggleMute"
|
||||
@go-to-conversation="goToConversation(activeCall || primaryIncomingCall)"
|
||||
|
||||
@@ -25,7 +25,7 @@ const store = useStore();
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const isSyncableDocument = doc =>
|
||||
!doc.pdf_document && doc.status === 'available';
|
||||
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
|
||||
|
||||
const syncableSelectedIds = computed(() => {
|
||||
if (!props.selectedIds.size) return [];
|
||||
|
||||
@@ -127,6 +127,7 @@ const menuItems = computed(() => {
|
||||
value: 'sync',
|
||||
action: 'sync',
|
||||
icon: 'i-lucide-refresh-cw',
|
||||
disabled: props.syncInProgress,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ import FileBubble from './bubbles/File.vue';
|
||||
import AudioBubble from './bubbles/Audio.vue';
|
||||
import VideoBubble from './bubbles/Video.vue';
|
||||
import EmbedBubble from './bubbles/Embed.vue';
|
||||
import FallbackBubble from './bubbles/Fallback.vue';
|
||||
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
|
||||
import EmailBubble from './bubbles/Email/Index.vue';
|
||||
import UnsupportedBubble from './bubbles/Unsupported.vue';
|
||||
@@ -329,8 +328,6 @@ const componentToRender = computed(() => {
|
||||
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
|
||||
const fileType = props.attachments[0].fileType;
|
||||
|
||||
if (fileType === ATTACHMENT_TYPES.FALLBACK) return FallbackBubble;
|
||||
|
||||
if (!props.content) {
|
||||
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
|
||||
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
|
||||
@@ -557,10 +554,11 @@ provideMessageContext({
|
||||
<Avatar v-bind="avatarInfo" :size="24" />
|
||||
</div>
|
||||
<div
|
||||
class="[grid-area:bubble] flex min-w-0"
|
||||
class="[grid-area:bubble] flex"
|
||||
:class="{
|
||||
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
|
||||
@@ -95,7 +95,7 @@ const replyToPreview = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="text-sm min-w-0"
|
||||
class="text-sm"
|
||||
:class="[
|
||||
messageClass,
|
||||
{
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import FormattedContent from './Text/FormattedContent.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { attachments, content } = useMessageContext();
|
||||
|
||||
const attachment = computed(() => attachments.value?.[0] || {});
|
||||
const url = computed(
|
||||
() => attachment.value.dataUrl || attachment.value.data_url
|
||||
);
|
||||
const title = computed(
|
||||
() =>
|
||||
attachment.value.fallbackTitle ||
|
||||
attachment.value.fallback_title ||
|
||||
url.value
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-3" data-bubble-name="fallback">
|
||||
<FormattedContent v-if="content" :content="content" class="mb-2" />
|
||||
<a
|
||||
v-if="url"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block max-w-[320px] truncate text-sm text-n-brand underline"
|
||||
>
|
||||
{{ title }}
|
||||
</a>
|
||||
<span v-else class="text-sm text-n-slate-11">
|
||||
{{ title }}
|
||||
</span>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -91,6 +91,10 @@ const wasDeclinedByAgent = computed(
|
||||
endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED
|
||||
);
|
||||
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
||||
const didCurrentUserAnswer = computed(
|
||||
() =>
|
||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
||||
);
|
||||
const conversationAssignee = computed(() => {
|
||||
const conversation = store.getters.getConversationById?.(
|
||||
conversationId?.value
|
||||
@@ -120,48 +124,43 @@ const durationSeconds = computed(() => {
|
||||
|
||||
const formattedDuration = computed(() => formatDuration(durationSeconds.value));
|
||||
|
||||
// Agent who handled the call (initiator on outbound, answerer on inbound), taken
|
||||
// strictly from the persisted accept fields — never the conversation's current
|
||||
// assignee, which would mis-attribute a historical call after a reassignment.
|
||||
const handlerName = computed(() => {
|
||||
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
|
||||
if (!acceptedByAgentId.value) return null;
|
||||
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
|
||||
return agent?.available_name || agent?.name || null;
|
||||
});
|
||||
|
||||
const handledBy = computed(() =>
|
||||
handlerName.value
|
||||
? t('CONVERSATION.VOICE_CALL.HANDLED_BY', { agentName: handlerName.value })
|
||||
: null
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
if (isFailed.value) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
|
||||
: 'CONVERSATION.VOICE_CALL.MISSED_CALL';
|
||||
}
|
||||
// RINGING or an as-yet-unknown/initial status: orient purely by direction so an
|
||||
// outbound call never falls through to the "Incoming call" label.
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtext = computed(() => {
|
||||
// Completed: "Handled by {agent} · 0:42" (drops either part when absent).
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? t('CONVERSATION.VOICE_CALL.CALLING')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return [handledBy.value, formattedDuration.value]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return formattedDuration.value;
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return handledBy.value;
|
||||
if (isOutbound.value) return null;
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
if (displayAgentName.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
|
||||
agentName: displayAgentName.value,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (isFailed.value) {
|
||||
// Missed/failed calls have no handler, so keep the reason rather than "Handled by".
|
||||
if (isOutbound.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT');
|
||||
}
|
||||
@@ -172,10 +171,6 @@ const subtext = computed(() => {
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
|
||||
}
|
||||
// RINGING or an as-yet-unknown/initial status.
|
||||
if (isOutbound.value) {
|
||||
return handledBy.value || t('CONVERSATION.VOICE_CALL.CALLING');
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
});
|
||||
|
||||
|
||||
@@ -31,17 +31,6 @@ const timeStampURL = computed(() => {
|
||||
return timeStampAppendedURL(attachment.dataUrl);
|
||||
});
|
||||
|
||||
const TRANSCRIPT_PREVIEW_LENGTH = 200;
|
||||
const isTranscriptExpanded = ref(false);
|
||||
const isTranscriptLong = computed(
|
||||
() => (attachment.transcribedText?.length || 0) > TRANSCRIPT_PREVIEW_LENGTH
|
||||
);
|
||||
const displayedTranscript = computed(() => {
|
||||
const text = attachment.transcribedText || '';
|
||||
if (!isTranscriptLong.value || isTranscriptExpanded.value) return text;
|
||||
return `${text.slice(0, TRANSCRIPT_PREVIEW_LENGTH).trimEnd()}…`;
|
||||
});
|
||||
|
||||
const audioPlayer = useTemplateRef('audioPlayer');
|
||||
|
||||
const isPlaying = ref(false);
|
||||
@@ -226,18 +215,7 @@ const downloadAudio = async () => {
|
||||
v-if="attachment.transcribedText && showTranscribedText"
|
||||
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
|
||||
>
|
||||
{{ displayedTranscript }}
|
||||
<button
|
||||
v-if="isTranscriptLong"
|
||||
class="block mt-1 p-0 border-0 bg-transparent text-n-slate-11 hover:text-n-slate-12 font-medium"
|
||||
@click="isTranscriptExpanded = !isTranscriptExpanded"
|
||||
>
|
||||
{{
|
||||
isTranscriptExpanded
|
||||
? $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_LESS')
|
||||
: $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_MORE')
|
||||
}}
|
||||
</button>
|
||||
{{ attachment.transcribedText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
sessions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['revoke', 'revokeAll', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const revokingId = ref(null);
|
||||
const revokingAll = ref(false);
|
||||
|
||||
const sortedSessions = computed(() =>
|
||||
[...props.sessions].sort(
|
||||
(a, b) => new Date(b.created_at) - new Date(a.created_at)
|
||||
)
|
||||
);
|
||||
|
||||
const formatDate = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return format(parseISO(dateStr), 'MMMM d, yyyy');
|
||||
};
|
||||
|
||||
const formatTime = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return format(parseISO(dateStr), 'hh:mma');
|
||||
};
|
||||
|
||||
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
|
||||
|
||||
const sessionLabel = session => {
|
||||
const parts = [];
|
||||
if (!isUnknown(session.browser_name)) parts.push(session.browser_name);
|
||||
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
|
||||
return parts.join(' on ') || t('SESSION_LIMIT.UNKNOWN_DEVICE');
|
||||
};
|
||||
|
||||
const handleRevoke = session => {
|
||||
revokingId.value = session.id;
|
||||
emit('revoke', session.id);
|
||||
};
|
||||
|
||||
const handleRevokeAll = () => {
|
||||
revokingAll.value = true;
|
||||
emit('revokeAll');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-lg mx-auto">
|
||||
<div
|
||||
class="bg-white shadow dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-n-slate-12">
|
||||
{{ $t('SESSION_LIMIT.TITLE') }}
|
||||
</h2>
|
||||
<p class="text-sm text-n-slate-11 mt-2">
|
||||
{{ $t('SESSION_LIMIT.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<NextButton
|
||||
type="button"
|
||||
faded
|
||||
sm
|
||||
class="flex-shrink-0 whitespace-nowrap"
|
||||
:label="$t('SESSION_LIMIT.END_ALL')"
|
||||
:is-loading="revokingAll"
|
||||
:disabled="revokingId !== null"
|
||||
@click="handleRevokeAll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Session List -->
|
||||
<div class="flex flex-col gap-3 max-h-80 overflow-y-auto">
|
||||
<div
|
||||
v-for="session in sortedSessions"
|
||||
:key="session.id"
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Icon
|
||||
icon="i-lucide-monitor"
|
||||
class="size-4 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ sessionLabel(session) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{
|
||||
`${$t('SESSION_LIMIT.STARTED')} ${formatDate(session.created_at)}, ${formatTime(session.created_at)}`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-n-slate-11 hover:text-n-slate-12 flex-shrink-0 disabled:opacity-50"
|
||||
:disabled="revokingId !== null || revokingAll"
|
||||
@click="handleRevoke(session)"
|
||||
>
|
||||
{{ $t('SESSION_LIMIT.END') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel -->
|
||||
<div class="text-center pt-4">
|
||||
<NextButton
|
||||
sm
|
||||
slate
|
||||
link
|
||||
type="button"
|
||||
class="w-full hover:!no-underline"
|
||||
:label="$t('SESSION_LIMIT.CANCEL')"
|
||||
@click="() => emit('cancel')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,10 +52,6 @@ export function useAccount() {
|
||||
});
|
||||
};
|
||||
|
||||
const finishOnboarding = async data => {
|
||||
await store.dispatch('accounts/finishOnboarding', data);
|
||||
};
|
||||
|
||||
return {
|
||||
accountId,
|
||||
route,
|
||||
@@ -65,6 +61,5 @@ export function useAccount() {
|
||||
isCloudFeatureEnabled,
|
||||
isOnChatwootCloud,
|
||||
updateAccount,
|
||||
finishOnboarding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,11 +154,6 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
export const SESSION_EVENTS = Object.freeze({
|
||||
LIMIT_HIT: 'Session limit reached at login',
|
||||
REVOKED_FROM_PROFILE: 'Revoked an active session',
|
||||
});
|
||||
|
||||
export const ONBOARDING_EVENTS = Object.freeze({
|
||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
|
||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import types from 'dashboard/store/mutation-types';
|
||||
|
||||
@@ -59,10 +58,6 @@ function extractCallerSnapshot(message) {
|
||||
// Snapshot caller info from the message at add-time so the widget can keep
|
||||
// rendering it after the user navigates away from a conversation list that
|
||||
// had the conversation hydrated (and Vuex evicts it from the store).
|
||||
// Only incoming messages carry the contact as the sender; on outbound calls
|
||||
// the sender is the initiating agent, so skip the snapshot and let the widget
|
||||
// fall back to the conversation's contact (conversation.meta.sender).
|
||||
if (message?.message_type !== MESSAGE_TYPE.INCOMING) return null;
|
||||
const sender = message?.sender;
|
||||
if (!sender) return null;
|
||||
return {
|
||||
|
||||
@@ -88,7 +88,6 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "Processing",
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
@@ -147,7 +146,6 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "Processing",
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
|
||||
@@ -86,16 +86,13 @@
|
||||
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
|
||||
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
|
||||
"CALL_ENDED": "Call ended",
|
||||
"HANDLED_BY": "Handled by {agentName}",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"CALLING": "Calling…",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "Join call",
|
||||
"CALL_BACK": "Call back",
|
||||
"TRANSCRIPT_SHOW_MORE": "Show more",
|
||||
"TRANSCRIPT_SHOW_LESS": "Show less"
|
||||
"CALL_BACK": "Call back"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
@@ -315,7 +312,6 @@
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"DISMISS_CALL": "Dismiss",
|
||||
"JOIN_CALL": "Join call",
|
||||
"END_CALL": "End call",
|
||||
"MUTE": "Mute mic",
|
||||
|
||||
@@ -40,7 +40,6 @@ import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
import onboarding from './onboarding.json';
|
||||
import sessionLimit from './sessionLimit.json';
|
||||
import yearInReview from './yearInReview.json';
|
||||
|
||||
export default {
|
||||
@@ -86,6 +85,5 @@ export default {
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
...onboarding,
|
||||
...sessionLimit,
|
||||
...yearInReview,
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"SESSION_LIMIT": {
|
||||
"TITLE": "Active session limit reached",
|
||||
"DESCRIPTION": "You have reached your limit of active sessions. Please end a session before logging in.",
|
||||
"END": "End",
|
||||
"END_ALL": "End all sessions",
|
||||
"LOG_IN": "Log in",
|
||||
"CANCEL": "Back to login",
|
||||
"UNKNOWN_DEVICE": "Unknown device",
|
||||
"STARTED": "Started"
|
||||
}
|
||||
}
|
||||
@@ -86,16 +86,6 @@
|
||||
"NOTE": "Manage additional security features for your account.",
|
||||
"MFA_BUTTON": "Manage Two-Factor Authentication"
|
||||
},
|
||||
"SESSIONS_SECTION": {
|
||||
"TITLE": "Active Sessions",
|
||||
"NOTE": "These are the devices currently logged in to your account.",
|
||||
"CURRENT": "Current session",
|
||||
"REVOKE": "Revoke",
|
||||
"REVOKE_SUCCESS": "Session revoked successfully",
|
||||
"REVOKE_ERROR": "Unable to revoke session. Please try again.",
|
||||
"FETCH_ERROR": "Unable to fetch sessions. Please try again.",
|
||||
"LAST_ACTIVE": "Last active"
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"NOTE": "This token can be used if you are building an API based integration",
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { accountId, currentAccount, finishOnboarding } = useAccount();
|
||||
const { accountId, currentAccount, updateAccount } = useAccount();
|
||||
const { enabledLanguages } = useConfig();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
@@ -195,12 +195,6 @@ const handleWebsiteEnter = () => {
|
||||
websiteInput.value?.blur();
|
||||
};
|
||||
|
||||
const normalizeWebsiteUrl = raw => {
|
||||
const trimmed = (raw || '').trim();
|
||||
if (!trimmed) return '';
|
||||
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Block submit while enrichment is still running so users can't bypass
|
||||
// the form with empty values — the controller would otherwise clear
|
||||
@@ -217,27 +211,9 @@ const handleSubmit = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect which enrichable fields the user actually edited *before*
|
||||
// normalizing — otherwise an untouched auto-filled domain
|
||||
// (acme.com -> https://acme.com) compares unequal against the raw snapshot
|
||||
// and gets falsely reported as changed, skewing onboarding telemetry.
|
||||
const init = initialValues.value;
|
||||
const enrichableFields = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
const fieldsChanged = Object.entries(enrichableFields)
|
||||
.filter(([key, val]) => val !== init[key])
|
||||
.map(([key]) => key);
|
||||
|
||||
// Persist with a scheme so downstream consumers (Firecrawl, portal
|
||||
// homepage_link) get a fully-qualified URL regardless of what the user typed.
|
||||
website.value = normalizeWebsiteUrl(website.value);
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await finishOnboarding({
|
||||
await updateAccount({
|
||||
name: accountName.value,
|
||||
locale: locale.value,
|
||||
website: website.value,
|
||||
@@ -248,11 +224,20 @@ const handleSubmit = async () => {
|
||||
user_role: userRole.value,
|
||||
});
|
||||
|
||||
const init = initialValues.value;
|
||||
const enrichableFields = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
|
||||
has_enriched_data: Boolean(
|
||||
currentAccount.value?.custom_attributes?.brand_info
|
||||
),
|
||||
fields_changed: fieldsChanged,
|
||||
fields_changed: Object.entries(enrichableFields)
|
||||
.filter(([key, val]) => val !== init[key])
|
||||
.map(([key]) => key),
|
||||
user_role: userRole.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
|
||||
@@ -253,6 +253,7 @@ export default {
|
||||
|
||||
if (
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.CHANNEL_VOICE
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import authAPI from 'dashboard/api/auth';
|
||||
import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper';
|
||||
import { SESSION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const sessions = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const relativeTime = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return formatDistanceToNow(parseISO(dateStr), { addSuffix: true });
|
||||
};
|
||||
|
||||
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
|
||||
|
||||
const deviceIcon = session => {
|
||||
const name = (session.device_name || '').toLowerCase();
|
||||
if (
|
||||
name.includes('iphone') ||
|
||||
name.includes('android') ||
|
||||
name.includes('mobile')
|
||||
) {
|
||||
return 'i-lucide-smartphone';
|
||||
}
|
||||
if (name.includes('ipad') || name.includes('tablet')) {
|
||||
return 'i-lucide-tablet';
|
||||
}
|
||||
return 'i-lucide-monitor';
|
||||
};
|
||||
|
||||
const sessionLabel = session => {
|
||||
const parts = [];
|
||||
if (!isUnknown(session.browser_name)) {
|
||||
parts.push(
|
||||
session.browser_version
|
||||
? `${session.browser_name} ${session.browser_version}`
|
||||
: session.browser_name
|
||||
);
|
||||
}
|
||||
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
|
||||
return parts.join(' on ') || t('SESSION_LIMIT.UNKNOWN_DEVICE');
|
||||
};
|
||||
|
||||
const locationLabel = session => {
|
||||
const parts = [];
|
||||
if (session.city) parts.push(session.city);
|
||||
if (session.country) parts.push(session.country);
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await authAPI.getSessions();
|
||||
sessions.value = data;
|
||||
} catch {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.FETCH_ERROR'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const revokeSession = async session => {
|
||||
try {
|
||||
await authAPI.revokeSession(session.id);
|
||||
sessions.value = sessions.value.filter(s => s.id !== session.id);
|
||||
AnalyticsHelper.track(SESSION_EVENTS.REVOKED_FROM_PROFILE);
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchSessions);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background p-4"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon
|
||||
:icon="deviceIcon(session)"
|
||||
class="size-5 mt-0.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ sessionLabel(session) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="session.current"
|
||||
class="rounded-full bg-n-teal-3 px-2 py-0.5 text-caption text-n-teal-11"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.CURRENT') }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="locationLabel(session)"
|
||||
class="text-body-b3 text-n-slate-11"
|
||||
>
|
||||
{{ locationLabel(session) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="session.last_activity_at"
|
||||
class="text-body-b3 text-n-slate-10"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.LAST_ACTIVE') }}
|
||||
{{ relativeTime(session.last_activity_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!session.current"
|
||||
type="button"
|
||||
faded
|
||||
xs
|
||||
:label="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE')"
|
||||
color-scheme="alert"
|
||||
@click="revokeSession(session)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,7 +20,6 @@ import SectionLayout from '../account/components/SectionLayout.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import AccessToken from './AccessToken.vue';
|
||||
import MfaSettingsCard from './MfaSettingsCard.vue';
|
||||
import ActiveSessions from './ActiveSessions.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import {
|
||||
@@ -43,7 +42,6 @@ export default {
|
||||
AudioNotifications,
|
||||
AccessToken,
|
||||
MfaSettingsCard,
|
||||
ActiveSessions,
|
||||
BaseSettingsHeader,
|
||||
},
|
||||
setup() {
|
||||
@@ -309,13 +307,6 @@ export default {
|
||||
>
|
||||
<MfaSettingsCard />
|
||||
</SectionLayout>
|
||||
<SectionLayout
|
||||
with-border
|
||||
:title="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.TITLE')"
|
||||
:description="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.NOTE')"
|
||||
>
|
||||
<ActiveSessions />
|
||||
</SectionLayout>
|
||||
<Policy :permissions="audioNotificationPermissions">
|
||||
<SectionLayout
|
||||
with-border
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import * as types from '../mutation-types';
|
||||
import AccountAPI from '../../api/account';
|
||||
import OnboardingAPI from '../../api/onboarding';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import EnterpriseAccountAPI from '../../api/enterprise/account';
|
||||
import { throwErrorMessage } from '../utils/api';
|
||||
@@ -84,15 +83,6 @@ export const actions = {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
finishOnboarding: async ({ commit }, payload) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
const response = await OnboardingAPI.update(payload);
|
||||
commit(types.default.EDIT_ACCOUNT, response.data);
|
||||
} finally {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
|
||||
}
|
||||
},
|
||||
delete: async ({ commit }, { id }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,7 @@ const state = {
|
||||
records: [],
|
||||
meta: {
|
||||
currentPage: 1,
|
||||
perPage: 25,
|
||||
perPage: 15,
|
||||
totalEntries: 0,
|
||||
},
|
||||
uiFlags: {
|
||||
|
||||
@@ -54,9 +54,7 @@ export const useCallsStore = defineStore('calls', {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepend so the newest call surfaces as the primary card (incomingCalls[0])
|
||||
// and older calls drop into the stack below it.
|
||||
this.calls.unshift({
|
||||
this.calls.push({
|
||||
...callData,
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
@@ -43,15 +43,6 @@ export const login = async ({
|
||||
mfaToken: error.response.data.mfa_token,
|
||||
};
|
||||
}
|
||||
if (
|
||||
error.response?.status === 409 &&
|
||||
error.response?.data?.sessions_limit_reached
|
||||
) {
|
||||
return {
|
||||
sessionsLimitReached: true,
|
||||
sessions: error.response.data.sessions,
|
||||
};
|
||||
}
|
||||
const loginError = new Error(parseAPIErrorResponse(error));
|
||||
loginError.errorCode = error.response?.data?.error_code;
|
||||
throw loginError;
|
||||
|
||||
@@ -8,8 +8,6 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper';
|
||||
import { SESSION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
// components
|
||||
import SimpleDivider from '../../components/Divider/SimpleDivider.vue';
|
||||
@@ -19,7 +17,6 @@ import Spinner from 'shared/components/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
|
||||
import SessionLimitOverlay from 'dashboard/components/auth/SessionLimitOverlay.vue';
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
|
||||
@@ -39,7 +36,6 @@ export default {
|
||||
NextButton,
|
||||
SimpleDivider,
|
||||
MfaVerification,
|
||||
SessionLimitOverlay,
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
@@ -72,8 +68,6 @@ export default {
|
||||
error: '',
|
||||
mfaRequired: false,
|
||||
mfaToken: null,
|
||||
sessionsLimitReached: false,
|
||||
limitedSessions: [],
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
@@ -188,15 +182,6 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if sessions limit reached
|
||||
if (result?.sessionsLimitReached) {
|
||||
this.loginApi.showLoading = false;
|
||||
this.sessionsLimitReached = true;
|
||||
this.limitedSessions = result.sessions;
|
||||
AnalyticsHelper.track(SESSION_EVENTS.LIMIT_HIT);
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleImpersonation();
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
@@ -239,51 +224,6 @@ export default {
|
||||
this.mfaToken = null;
|
||||
this.credentials.password = '';
|
||||
},
|
||||
retryLoginWithParams(extraParams) {
|
||||
const credentials = {
|
||||
email: this.email
|
||||
? decodeURIComponent(this.email)
|
||||
: this.credentials.email,
|
||||
password: this.credentials.password,
|
||||
sso_auth_token: this.ssoAuthToken,
|
||||
ssoAccountId: this.ssoAccountId,
|
||||
ssoConversationId: this.ssoConversationId,
|
||||
...extraParams,
|
||||
};
|
||||
|
||||
this.sessionsLimitReached = false;
|
||||
this.limitedSessions = [];
|
||||
this.loginApi.showLoading = true;
|
||||
login(credentials)
|
||||
.then(result => {
|
||||
if (result?.sessionsLimitReached) {
|
||||
this.loginApi.showLoading = false;
|
||||
this.sessionsLimitReached = true;
|
||||
this.limitedSessions = result.sessions;
|
||||
AnalyticsHelper.track(SESSION_EVENTS.LIMIT_HIT);
|
||||
return;
|
||||
}
|
||||
this.handleImpersonation();
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
.catch(response => {
|
||||
this.loginApi.hasErrored = true;
|
||||
this.showAlertMessage(
|
||||
response?.message || this.$t('LOGIN.API.UNAUTH')
|
||||
);
|
||||
});
|
||||
},
|
||||
handleSessionRevoke(sessionId) {
|
||||
this.retryLoginWithParams({ revoke_session_id: sessionId });
|
||||
},
|
||||
handleSessionRevokeAll() {
|
||||
this.retryLoginWithParams({ revoke_all_sessions: true });
|
||||
},
|
||||
handleSessionLimitCancel() {
|
||||
this.sessionsLimitReached = false;
|
||||
this.limitedSessions = [];
|
||||
this.credentials.password = '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -315,18 +255,8 @@ export default {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Session Limit Section -->
|
||||
<section v-if="sessionsLimitReached" class="mt-11">
|
||||
<SessionLimitOverlay
|
||||
:sessions="limitedSessions"
|
||||
@revoke="handleSessionRevoke"
|
||||
@revoke-all="handleSessionRevokeAll"
|
||||
@cancel="handleSessionLimitCancel"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- MFA Verification Section -->
|
||||
<section v-else-if="mfaRequired" class="mt-11">
|
||||
<section v-if="mfaRequired" class="mt-11">
|
||||
<MfaVerification
|
||||
:mfa-token="mfaToken"
|
||||
@verified="handleMfaVerified"
|
||||
|
||||
@@ -23,5 +23,3 @@ class Internal::TriggerDailyScheduledItemsJob < ApplicationJob
|
||||
@designated_minute ||= Digest::MD5.hexdigest(ChatwootHub.installation_identifier).hex % 1440
|
||||
end
|
||||
end
|
||||
|
||||
Internal::TriggerDailyScheduledItemsJob.prepend_mod_with('Internal::TriggerDailyScheduledItemsJob')
|
||||
|
||||
+3
-17
@@ -47,7 +47,7 @@ class Campaign < ApplicationRecord
|
||||
|
||||
enum campaign_type: { ongoing: 0, one_off: 1 }
|
||||
# TODO : enabled attribute is unneccessary . lets move that to the campaign status with additional statuses like draft, disabled etc.
|
||||
enum campaign_status: { active: 0, completed: 1, processing: 2 }
|
||||
enum campaign_status: { active: 0, completed: 1 }
|
||||
|
||||
has_many :conversations, dependent: :nullify, autosave: true
|
||||
|
||||
@@ -56,27 +56,13 @@ class Campaign < ApplicationRecord
|
||||
|
||||
def trigger!
|
||||
return unless one_off?
|
||||
return unless feature_enabled?
|
||||
return unless mark_processing!
|
||||
return if completed?
|
||||
|
||||
execute_campaign
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def feature_enabled?
|
||||
inbox.inbox_type != 'Whatsapp' || account.feature_enabled?(:whatsapp_campaign)
|
||||
end
|
||||
|
||||
def mark_processing!
|
||||
# Multiple scheduler jobs can pick the same active campaign; lock before flipping status to avoid duplicate sends.
|
||||
with_lock do
|
||||
next if completed? || processing?
|
||||
|
||||
processing!
|
||||
end
|
||||
end
|
||||
|
||||
def execute_campaign
|
||||
case inbox.inbox_type
|
||||
when 'Twilio SMS'
|
||||
@@ -84,7 +70,7 @@ class Campaign < ApplicationRecord
|
||||
when 'Sms'
|
||||
Sms::OneoffSmsCampaignService.new(campaign: self).perform
|
||||
when 'Whatsapp'
|
||||
Whatsapp::OneoffCampaignService.new(campaign: self).perform
|
||||
Whatsapp::OneoffCampaignService.new(campaign: self).perform if account.feature_enabled?(:whatsapp_campaign)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -41,19 +41,19 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
end
|
||||
|
||||
# Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers.
|
||||
# Meta's Calling API is available to any whatsapp_cloud inbox (embedded-signup or manual keys);
|
||||
# only 360dialog (default provider) can't reach the call APIs.
|
||||
# Meta's Calling API is only available via the embedded-signup whatsapp_cloud flow —
|
||||
# 360dialog (default provider) and manual whatsapp_cloud setups can't reach the call APIs.
|
||||
def voice_enabled?
|
||||
voice_calling_supported? &&
|
||||
provider_config['calling_enabled'].present? &&
|
||||
account.feature_enabled?('channel_voice')
|
||||
end
|
||||
|
||||
# Whether this inbox can do WhatsApp calling at all. Meta's Calling API is
|
||||
# reachable by any whatsapp_cloud inbox, so 360dialog inboxes can't be toggled
|
||||
# on even though calling_enabled would persist.
|
||||
# Whether this inbox can do WhatsApp calling at all. Meta's Calling API is only
|
||||
# reachable via the embedded-signup whatsapp_cloud flow, so manual whatsapp_cloud
|
||||
# and 360dialog inboxes can't be toggled on even though calling_enabled would persist.
|
||||
def voice_calling_supported?
|
||||
provider == 'whatsapp_cloud'
|
||||
provider == 'whatsapp_cloud' && provider_config['source'] == 'embedded_signup'
|
||||
end
|
||||
|
||||
def provider_service
|
||||
@@ -69,7 +69,7 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
# Saved with validate: false to skip validate_provider_config's remote credential
|
||||
# re-check, which could spuriously fail and desync the flag from Meta.
|
||||
def enable_voice_calling!
|
||||
raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported?
|
||||
raise 'WhatsApp calling requires an embedded-signup whatsapp_cloud inbox' unless voice_calling_supported?
|
||||
raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice')
|
||||
|
||||
provider_service.update_calling_status('ENABLED')
|
||||
@@ -82,7 +82,7 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
# `calls` from the webhook subscription (best-effort, so a Meta outage can't
|
||||
# trap admins). Leaves Meta's WABA calling.status untouched.
|
||||
def disable_voice_calling!
|
||||
raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported?
|
||||
raise 'WhatsApp calling requires an embedded-signup whatsapp_cloud inbox' unless voice_calling_supported?
|
||||
|
||||
self.provider_config = provider_config.merge('calling_enabled' => false)
|
||||
save!(validate: false)
|
||||
|
||||
@@ -101,7 +101,6 @@ class User < ApplicationRecord
|
||||
has_many :messages, as: :sender, dependent: :nullify
|
||||
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
|
||||
|
||||
has_many :user_sessions, dependent: :destroy
|
||||
has_many :custom_filters, dependent: :destroy_async
|
||||
has_many :dashboard_apps, dependent: :nullify
|
||||
has_many :mentions, dependent: :destroy_async
|
||||
@@ -119,7 +118,6 @@ class User < ApplicationRecord
|
||||
|
||||
before_validation :set_password_and_uid, on: :create
|
||||
after_destroy :remove_macros
|
||||
after_save :sync_user_sessions, if: :saved_change_to_tokens?
|
||||
|
||||
scope :order_by_full_name, -> { order('lower(name) ASC') }
|
||||
|
||||
@@ -216,11 +214,6 @@ class User < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def sync_user_sessions
|
||||
active_client_ids = (tokens || {}).keys
|
||||
user_sessions.where.not(client_id: active_client_ids).destroy_all
|
||||
end
|
||||
|
||||
def remove_macros
|
||||
macros.personal.destroy_all
|
||||
end
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: user_sessions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# browser_name :string
|
||||
# browser_version :string
|
||||
# city :string
|
||||
# country :string
|
||||
# country_code :string
|
||||
# device_name :string
|
||||
# ip_address :string
|
||||
# last_activity_at :datetime
|
||||
# platform_name :string
|
||||
# platform_version :string
|
||||
# user_agent :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# client_id :string not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_user_sessions_on_user_id (user_id)
|
||||
# index_user_sessions_on_user_id_and_client_id (user_id,client_id) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
|
||||
class UserSession < ApplicationRecord
|
||||
ACTIVITY_THROTTLE = 5.minutes
|
||||
|
||||
belongs_to :user
|
||||
|
||||
validates :client_id, presence: true, uniqueness: { scope: :user_id }
|
||||
|
||||
def current?(active_client_id)
|
||||
client_id == active_client_id
|
||||
end
|
||||
|
||||
def should_update_activity?
|
||||
last_activity_at.nil? || last_activity_at < ACTIVITY_THROTTLE.ago
|
||||
end
|
||||
end
|
||||
@@ -51,5 +51,3 @@ class ContactPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
ContactPolicy.prepend_mod_with('ContactPolicy')
|
||||
|
||||
@@ -5,10 +5,12 @@ class Sms::OneoffSmsCampaignService
|
||||
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Sms' || !campaign.one_off?
|
||||
raise 'Completed Campaign' if campaign.completed?
|
||||
|
||||
# marks campaign completed so that other jobs won't pick it up
|
||||
campaign.completed!
|
||||
|
||||
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
|
||||
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
|
||||
process_audience(audience_labels)
|
||||
campaign.completed!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -5,10 +5,12 @@ class Twilio::OneoffSmsCampaignService
|
||||
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Twilio SMS' || !campaign.one_off?
|
||||
raise 'Completed Campaign' if campaign.completed?
|
||||
|
||||
# marks campaign completed so that other jobs won't pick it up
|
||||
campaign.completed!
|
||||
|
||||
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
|
||||
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
|
||||
process_audience(audience_labels)
|
||||
campaign.completed!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
class UserSessionTrackingService
|
||||
def initialize(user:, request:, client_id:)
|
||||
@user = user
|
||||
@request = request
|
||||
@client_id = client_id
|
||||
end
|
||||
|
||||
def create_or_update!
|
||||
session = @user.user_sessions.find_or_initialize_by(client_id: @client_id)
|
||||
session.assign_attributes(session_attributes)
|
||||
session.last_activity_at = Time.current
|
||||
session.save!
|
||||
session
|
||||
end
|
||||
|
||||
def update_activity!
|
||||
session = @user.user_sessions.find_by(client_id: @client_id)
|
||||
return unless session&.should_update_activity?
|
||||
|
||||
session.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def session_attributes
|
||||
browser = Browser.new(@request.user_agent)
|
||||
location = IpLookupService.new.perform(@request.remote_ip)
|
||||
|
||||
{
|
||||
ip_address: @request.remote_ip,
|
||||
user_agent: @request.user_agent,
|
||||
browser_name: browser.name,
|
||||
browser_version: browser.full_version,
|
||||
device_name: browser.device.name,
|
||||
platform_name: browser.platform.name,
|
||||
platform_version: browser.platform.version,
|
||||
city: location&.city,
|
||||
country: location&.country,
|
||||
country_code: location&.country_code
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,3 @@
|
||||
require 'resolv'
|
||||
|
||||
class WebsiteBrandingService
|
||||
include SocialLinkParser
|
||||
|
||||
@@ -26,8 +24,7 @@ class WebsiteBrandingService
|
||||
colors: extract_colors(doc),
|
||||
logos: extract_logos(doc),
|
||||
socials: build_socials(links),
|
||||
email: @email,
|
||||
email_provider: detect_email_provider
|
||||
email: @email
|
||||
})
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WebsiteBranding] #{e.message}"
|
||||
@@ -107,37 +104,6 @@ class WebsiteBrandingService
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
GOOGLE_MX_DOMAINS = %w[google.com googlemail.com].freeze
|
||||
MICROSOFT_MX_DOMAINS = %w[outlook.com].freeze
|
||||
|
||||
# Probes the domain's MX records to infer the mailbox provider, returning
|
||||
# 'google' or 'microsoft' (matching Channel::Email#provider) or nil when unknown.
|
||||
def detect_email_provider
|
||||
hosts = mx_records
|
||||
return 'google' if mx_hosted_by?(hosts, GOOGLE_MX_DOMAINS)
|
||||
return 'microsoft' if mx_hosted_by?(hosts, MICROSOFT_MX_DOMAINS)
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
# Matches on the registrable domain of the MX host (anchored on a label
|
||||
# boundary) so lookalikes like "notgoogle.com" don't get misclassified.
|
||||
def mx_hosted_by?(hosts, provider_domains)
|
||||
hosts.any? do |host|
|
||||
provider_domains.any? { |domain| host == domain || host.end_with?(".#{domain}") }
|
||||
end
|
||||
end
|
||||
|
||||
def mx_records
|
||||
Resolv::DNS.open do |resolver|
|
||||
resolver.timeouts = 5
|
||||
resolver.getresources(@domain, Resolv::DNS::Resource::IN::MX).map { |record| record.exchange.to_s.downcase }
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WebsiteBranding] MX probe failed for #{@domain}: #{e.message}"
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
WebsiteBrandingService.prepend_mod_with('WebsiteBrandingService')
|
||||
|
||||
@@ -3,8 +3,9 @@ class Whatsapp::OneoffCampaignService
|
||||
|
||||
def perform
|
||||
validate_campaign!
|
||||
process_audience(extract_audience_labels)
|
||||
# marks campaign completed so that other jobs won't pick it up
|
||||
campaign.completed!
|
||||
process_audience(extract_audience_labels)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
json.array! @sessions do |session|
|
||||
json.id session.id
|
||||
json.browser_name session.browser_name
|
||||
json.browser_version session.browser_version
|
||||
json.device_name session.device_name
|
||||
json.platform_name session.platform_name
|
||||
json.platform_version session.platform_version
|
||||
json.ip_address session.ip_address
|
||||
json.city session.city
|
||||
json.country session.country
|
||||
json.country_code session.country_code
|
||||
json.last_activity_at session.last_activity_at
|
||||
json.created_at session.created_at
|
||||
json.current session.current?(@current_client_id)
|
||||
end
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.14.1'
|
||||
version: '4.14.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -15,7 +15,7 @@ DeviseTokenAuth.setup do |config|
|
||||
|
||||
# Sets the max number of concurrent devices per user, which is 10 by default.
|
||||
# After this limit is reached, the oldest tokens will be removed.
|
||||
config.max_number_of_devices = 10
|
||||
config.max_number_of_devices = 25
|
||||
|
||||
# Sometimes it's necessary to make several requests to the API at the same
|
||||
# time. In this case, each request in the batch will need to share the same
|
||||
|
||||
@@ -170,15 +170,25 @@ class Rack::Attack
|
||||
###-----------Widget API Throttling---------------###
|
||||
###-----------------------------------------------###
|
||||
|
||||
## Burst protection on widget conversation creation. Keyed on (IP, website_token)
|
||||
## so distinct widgets behind a shared NAT IP get separate buckets. Independent
|
||||
## kill switch so it can be toggled without touching the legacy ENABLE_RACK_ATTACK_WIDGET_API.
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_CONVERSATIONS', true))
|
||||
throttle('api/v1/widget/conversations',
|
||||
limit: ENV.fetch('RATE_LIMIT_WIDGET_CONVERSATIONS', '30').to_i,
|
||||
period: 1.minute) do |req|
|
||||
next unless req.path_without_extentions == '/api/v1/widget/conversations' && req.post?
|
||||
|
||||
token = req.params['website_token'].presence ||
|
||||
ActionDispatch::Request.new(req.env).params['website_token'].presence
|
||||
"#{req.ip}:#{token}" if token
|
||||
end
|
||||
end
|
||||
|
||||
# Rack attack on widget APIs can be disabled by setting ENABLE_RACK_ATTACK_WIDGET_API to false
|
||||
# For clients using the widgets in specific conditions like inside and iframe
|
||||
# TODO: Deprecate this feature in future after finding a better solution
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_API', true))
|
||||
## 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?
|
||||
end
|
||||
|
||||
## Prevent Contact update Bombing in Widget API ###
|
||||
throttle('api/v1/widget/contacts', limit: 60, period: 1.hour) do |req|
|
||||
req.ip if req.path_without_extentions == '/api/v1/widget/contacts' && (req.patch? || req.put?)
|
||||
|
||||
@@ -215,18 +215,6 @@
|
||||
value:
|
||||
locked: false
|
||||
type: code
|
||||
- name: CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT
|
||||
display_title: 'Captain Document Auto Sync Per Account Batch Limit'
|
||||
description: 'Maximum syncable Captain documents to enqueue per account in one scheduler run. Defaults to 50.'
|
||||
value: 50
|
||||
locked: false
|
||||
type: number
|
||||
- name: CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT
|
||||
display_title: 'Captain Document Auto Sync Global Batch Limit'
|
||||
description: 'Maximum syncable Captain documents to enqueue globally in one scheduler run. Defaults to 1000.'
|
||||
value: 1000
|
||||
locked: false
|
||||
type: number
|
||||
# End of Captain Config
|
||||
|
||||
# ------- Context.dev Config ------- #
|
||||
|
||||
@@ -47,10 +47,6 @@ en:
|
||||
saml_not_available: SAML authentication is not available in this installation.
|
||||
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
|
||||
|
||||
profile_settings:
|
||||
sessions:
|
||||
cannot_revoke_current: You cannot revoke the current session.
|
||||
|
||||
errors:
|
||||
account:
|
||||
reporting_timezone:
|
||||
|
||||
@@ -54,7 +54,6 @@ Rails.application.routes.draw do
|
||||
resource :contact_merge, only: [:create]
|
||||
end
|
||||
resource :bulk_actions, only: [:create]
|
||||
resource :onboarding, only: [:update]
|
||||
resources :agents, only: [:index, :create, :update, :destroy] do
|
||||
post :bulk_create, on: :collection
|
||||
end
|
||||
@@ -433,7 +432,6 @@ Rails.application.routes.draw do
|
||||
post :verify
|
||||
post :backup_codes
|
||||
end
|
||||
resources :sessions, only: [:index, :destroy]
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
class CreateUserSessions < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :user_sessions do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.string :client_id, null: false
|
||||
t.string :ip_address
|
||||
t.string :user_agent
|
||||
t.string :browser_name
|
||||
t.string :browser_version
|
||||
t.string :device_name
|
||||
t.string :platform_name
|
||||
t.string :platform_version
|
||||
t.string :city
|
||||
t.string :country
|
||||
t.string :country_code
|
||||
t.datetime :last_activity_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :user_sessions, [:user_id, :client_id], unique: true
|
||||
end
|
||||
end
|
||||
@@ -1252,26 +1252,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
|
||||
t.index ["name", "account_id"], name: "index_teams_on_name_and_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "user_sessions", force: :cascade do |t|
|
||||
t.bigint "user_id", null: false
|
||||
t.string "client_id", null: false
|
||||
t.string "ip_address"
|
||||
t.string "user_agent"
|
||||
t.string "browser_name"
|
||||
t.string "browser_version"
|
||||
t.string "device_name"
|
||||
t.string "platform_name"
|
||||
t.string "platform_version"
|
||||
t.string "city"
|
||||
t.string "country"
|
||||
t.string "country_code"
|
||||
t.datetime "last_activity_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["user_id", "client_id"], name: "index_user_sessions_on_user_id_and_client_id", unique: true
|
||||
t.index ["user_id"], name: "index_user_sessions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "users", id: :serial, force: :cascade do |t|
|
||||
t.string "provider", default: "email", null: false
|
||||
t.string "uid", default: "", null: false
|
||||
@@ -1344,7 +1324,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "inboxes", "portals"
|
||||
add_foreign_key "user_sessions", "users"
|
||||
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
|
||||
on("accounts").
|
||||
after(:insert).
|
||||
|
||||
@@ -2,7 +2,7 @@ class Api::V1::Accounts::AuditLogsController < Api::V1::Accounts::EnterpriseAcco
|
||||
before_action :check_admin_authorization?
|
||||
before_action :fetch_audit
|
||||
|
||||
RESULTS_PER_PAGE = 25
|
||||
RESULTS_PER_PAGE = 15
|
||||
|
||||
def show
|
||||
@audit_logs = @audit_logs.page(params[:page]).per(RESULTS_PER_PAGE)
|
||||
|
||||
@@ -75,6 +75,7 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
||||
Current.account.captain_documents.where(id: params[:ids]).find_each(batch_size: 100) do |document|
|
||||
next unless document.syncable?
|
||||
next unless document.available?
|
||||
next if document.sync_in_progress?
|
||||
|
||||
document.update!(
|
||||
sync_status: :syncing,
|
||||
|
||||
@@ -37,6 +37,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
def sync
|
||||
return render_could_not_create_error(I18n.t('captain.documents.sync_not_supported_for_pdf')) unless @document.syncable?
|
||||
return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available?
|
||||
return render_could_not_create_error(I18n.t('captain.documents.sync_already_in_progress')) if @document.sync_in_progress?
|
||||
|
||||
@document.update!(
|
||||
sync_status: :syncing,
|
||||
|
||||
@@ -35,14 +35,8 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def initiate
|
||||
@call = create_outbound_call
|
||||
# Link the call to its message in one transaction so the message.created
|
||||
# broadcast (an after_create_commit hook) fires only once call.message_id is
|
||||
# set. Otherwise the live ringing bubble receives a message with no `call`
|
||||
# payload (no direction/agent) and renders "Calling…" instead of "Handled by …".
|
||||
ActiveRecord::Base.transaction do
|
||||
@message = Voice::CallMessageBuilder.new(@call).perform!
|
||||
@call.update!(message_id: @message.id)
|
||||
end
|
||||
@message = Voice::CallMessageBuilder.new(@call).perform!
|
||||
@call.update!(message_id: @message.id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -48,7 +48,9 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
|
||||
return if document.pdf_document?
|
||||
|
||||
with_lock(lock_key(document), LOCK_TIMEOUT) do
|
||||
perform_sync(document, start_time)
|
||||
mark_sync_started(document)
|
||||
result = Captain::Documents::SyncService.new(document.reload).perform
|
||||
log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
|
||||
end
|
||||
rescue LockAcquisitionError
|
||||
log_sync_outcome(document, result: :already_syncing)
|
||||
@@ -62,12 +64,6 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def perform_sync(document, start_time)
|
||||
mark_sync_started(document)
|
||||
result = Captain::Documents::SyncService.new(document.reload).perform
|
||||
log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
|
||||
end
|
||||
|
||||
def log_sync_outcome(document, **fields)
|
||||
payload = {
|
||||
document_id: document.id,
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
DEFAULT_PER_ACCOUNT_BATCH_LIMIT = 50
|
||||
DEFAULT_GLOBAL_BATCH_LIMIT = 1000
|
||||
PER_ACCOUNT_HOURLY_CAP = 50
|
||||
GLOBAL_HOURLY_CAP = 1000
|
||||
DUE_DOCUMENT_BATCH_SIZE = PER_ACCOUNT_HOURLY_CAP * 2 # Inspite of skipping, we should at least reach the hourly cap
|
||||
SYNC_STALE_TIMEOUT = Captain::Document::SYNC_STALE_TIMEOUT
|
||||
DAILY_SYNC_JITTER = 4.hours
|
||||
WEEKLY_SYNC_JITTER = 1.day
|
||||
MONTHLY_SYNC_JITTER = 4.days
|
||||
|
||||
def perform(plan_name = nil)
|
||||
@per_account_batch_limit = configured_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', DEFAULT_PER_ACCOUNT_BATCH_LIMIT)
|
||||
@global_batch_limit = configured_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', DEFAULT_GLOBAL_BATCH_LIMIT)
|
||||
@remaining_global_capacity = @global_batch_limit
|
||||
@plan_name = plan_name.to_s.downcase.presence
|
||||
def perform
|
||||
@remaining_global_capacity = GLOBAL_HOURLY_CAP
|
||||
sync_intervals = Enterprise::Account.captain_document_sync_intervals
|
||||
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 }
|
||||
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0, documents_skipped: 0 }
|
||||
|
||||
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
|
||||
break if @remaining_global_capacity <= 0
|
||||
|
||||
stats[:accounts_scanned] += 1
|
||||
next unless account.feature_enabled?('captain_document_auto_sync')
|
||||
next unless account_in_selected_plan?(account)
|
||||
|
||||
stats[:accounts_enabled] += 1
|
||||
interval = account.captain_document_sync_interval(sync_intervals)
|
||||
@@ -30,6 +24,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
stats[:accounts_scheduled] += 1
|
||||
result = enqueue_due_documents(account, interval)
|
||||
stats[:documents_enqueued] += result[:enqueued]
|
||||
stats[:documents_skipped] += result[:skipped]
|
||||
end
|
||||
|
||||
log_scheduler_summary(stats)
|
||||
@@ -38,85 +33,92 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
private
|
||||
|
||||
def enqueue_due_documents(account, interval)
|
||||
per_account_limit = [@per_account_batch_limit, @remaining_global_capacity].min
|
||||
result = { enqueued: 0 }
|
||||
per_account_limit = [PER_ACCOUNT_HOURLY_CAP, @remaining_global_capacity].min
|
||||
result = { enqueued: 0, skipped: 0 }
|
||||
skipped_document_ids = []
|
||||
|
||||
due_documents(account, interval).limit(per_account_limit).to_a.each do |document|
|
||||
process_due_document(document, interval, result)
|
||||
while result[:enqueued] < per_account_limit
|
||||
|
||||
documents = due_documents(account, interval, skipped_document_ids).limit(DUE_DOCUMENT_BATCH_SIZE).to_a
|
||||
break if documents.empty?
|
||||
|
||||
documents.each do |document|
|
||||
break if result[:enqueued] >= per_account_limit
|
||||
|
||||
process_due_document(document, result, skipped_document_ids)
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def process_due_document(document, interval, result)
|
||||
sync_execution_delay = sync_jitter(interval)
|
||||
def process_due_document(document, result, skipped_document_ids)
|
||||
return unless document.syncable?
|
||||
|
||||
Captain::Documents::PerformSyncJob.set(queue: :purgable, wait: sync_execution_delay).perform_later(document)
|
||||
# Reserve the sync slot before enqueueing so later scheduler runs skip this document while the job is queued.
|
||||
unless reserve_sync_slot(document)
|
||||
result[:skipped] += 1
|
||||
skipped_document_ids << document.id
|
||||
return
|
||||
end
|
||||
|
||||
Captain::Documents::PerformSyncJob.perform_later(document)
|
||||
@remaining_global_capacity -= 1
|
||||
result[:enqueued] += 1
|
||||
end
|
||||
|
||||
def due_documents(account, interval)
|
||||
def due_documents(account, interval, skipped_document_ids)
|
||||
syncing = Captain::Document.sync_statuses[:syncing]
|
||||
synced = Captain::Document.sync_statuses[:synced]
|
||||
failed = Captain::Document.sync_statuses[:failed]
|
||||
stale_cutoff = SYNC_STALE_TIMEOUT.ago
|
||||
# The scheduler runs at predictable plan windows. Use a wider due window so
|
||||
# jittered executions do not miss the next window just because they finished later.
|
||||
sync_due_before = due_window(interval).ago
|
||||
|
||||
documents = account.captain_documents.syncable.where(status: :available).where(
|
||||
'(sync_status = ? AND last_synced_at < ?) OR (sync_status = ? AND last_sync_attempted_at < ?) OR ' \
|
||||
'(sync_status = ? AND last_sync_attempted_at < ?)',
|
||||
synced, sync_due_before, failed, sync_due_before, syncing, stale_cutoff
|
||||
synced, interval.ago, failed, interval.ago, syncing, SYNC_STALE_TIMEOUT.ago
|
||||
)
|
||||
documents = documents.where.not(id: skipped_document_ids) if skipped_document_ids.present?
|
||||
documents.order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id)
|
||||
end
|
||||
|
||||
def configured_sync_limit(config_key, default)
|
||||
configured_value = InstallationConfig.find_by(name: config_key)&.value
|
||||
limit = configured_value.to_s.to_i
|
||||
limit.positive? ? limit : default
|
||||
def reserve_sync_slot(document)
|
||||
mark_sync_started(document)
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
log_document_skip(document, e)
|
||||
false
|
||||
end
|
||||
|
||||
def account_in_selected_plan?(account)
|
||||
return true if @plan_name.blank?
|
||||
def log_document_skip(document, error)
|
||||
payload = {
|
||||
event: 'document_skipped',
|
||||
document_id: document.id,
|
||||
account_id: document.account_id,
|
||||
assistant_id: document.assistant_id,
|
||||
error_class: error.class.name,
|
||||
error_message: error.message,
|
||||
validation_errors: document.errors.full_messages
|
||||
}
|
||||
|
||||
account_sync_plan(account) == @plan_name
|
||||
end
|
||||
|
||||
def account_sync_plan(account)
|
||||
plan = account.custom_attributes['plan_name']
|
||||
plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise?
|
||||
plan.to_s.downcase.presence
|
||||
end
|
||||
|
||||
def sync_jitter(interval)
|
||||
jitter_window = if interval <= 1.day
|
||||
DAILY_SYNC_JITTER
|
||||
elsif interval <= 1.week
|
||||
WEEKLY_SYNC_JITTER
|
||||
else
|
||||
MONTHLY_SYNC_JITTER
|
||||
end
|
||||
|
||||
rand(0..jitter_window.to_i).seconds
|
||||
end
|
||||
|
||||
def due_window(interval)
|
||||
(interval.to_i / 2).seconds
|
||||
Rails.logger.warn("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
|
||||
end
|
||||
|
||||
def log_scheduler_summary(stats)
|
||||
payload = {
|
||||
event: 'completed',
|
||||
plan_name: @plan_name,
|
||||
global_cap_hit: @remaining_global_capacity <= 0,
|
||||
per_account_batch_limit: @per_account_batch_limit,
|
||||
global_batch_limit: @global_batch_limit,
|
||||
remaining_global_capacity: @remaining_global_capacity
|
||||
}.merge(stats)
|
||||
|
||||
Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
|
||||
end
|
||||
|
||||
def mark_sync_started(document)
|
||||
document.update!(
|
||||
sync_status: :syncing,
|
||||
sync_step: nil,
|
||||
last_sync_error_code: nil,
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
module Enterprise::Internal::TriggerDailyScheduledItemsJob
|
||||
def perform
|
||||
super
|
||||
|
||||
Captain::Documents::ScheduleSyncsJob.perform_later('enterprise')
|
||||
Captain::Documents::ScheduleSyncsJob.perform_later('business') if business_auto_sync_due?
|
||||
Captain::Documents::ScheduleSyncsJob.perform_later('startups') if startup_auto_sync_due?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def business_auto_sync_due?
|
||||
Time.current.utc.sunday?
|
||||
end
|
||||
|
||||
def startup_auto_sync_due?
|
||||
Time.current.utc.day == 1
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Internal::TriggerHourlyScheduledItemsJob
|
||||
def perform
|
||||
super
|
||||
|
||||
Captain::Documents::ScheduleSyncsJob.perform_later
|
||||
end
|
||||
end
|
||||
@@ -31,11 +31,10 @@ module Enterprise::Webhooks::WhatsappEventsJob
|
||||
# and timer/recorder kick off before the contact actually answers.
|
||||
def handle_call_events(channel, params)
|
||||
value = params.dig(:entry, 0, :changes, 0, :value) || {}
|
||||
contacts = value[:contacts]
|
||||
|
||||
Array(value[:calls]).each do |call_payload|
|
||||
with_call_lock(channel, call_payload[:id]) do
|
||||
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload], contacts: contacts }).perform
|
||||
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
module Enterprise::ContactPolicy
|
||||
def export?
|
||||
@account_user.custom_role&.permissions&.include?('contact_manage') || super
|
||||
end
|
||||
|
||||
def import?
|
||||
@account_user.custom_role&.permissions&.include?('contact_manage') || super
|
||||
end
|
||||
end
|
||||
@@ -16,18 +16,9 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
advanced_search_indexing
|
||||
advanced_search
|
||||
linear_integration
|
||||
channel_voice
|
||||
].freeze
|
||||
|
||||
BUSINESS_PLAN_FEATURES = %w[
|
||||
sla
|
||||
custom_roles
|
||||
csat_review_notes
|
||||
conversation_required_attributes
|
||||
advanced_assignment
|
||||
custom_tools
|
||||
companies
|
||||
].freeze
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||
PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ module Enterprise::WebsiteBrandingService
|
||||
socials: brand['socials'] || [],
|
||||
links: brand['links'],
|
||||
email: @email,
|
||||
email_provider: detect_email_provider,
|
||||
industries: brand.dig('industries', 'eic') || [],
|
||||
stock: brand['stock'],
|
||||
is_nsfw: brand['is_nsfw'] || false
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
TRANSCRIPTION_MODEL = 'gpt-4o-mini-transcribe'.freeze
|
||||
# OpenAI's transcription endpoint hard limit is 25 MB *decimal* (25_000_000), not
|
||||
# binary (25.megabytes = 26_214_400) — using the binary form leaks the 25.0–26.2 MB
|
||||
# range to the API as 413s. Long audio (~70+ min Opus) keeps the attachment but skips
|
||||
# transcription.
|
||||
TRANSCRIPTION_BYTE_LIMIT = 25_000_000
|
||||
WHISPER_MODEL = 'whisper-1'.freeze
|
||||
# Whisper's hard limit is 25 MB *decimal* (25_000_000), not binary (25.megabytes
|
||||
# = 26_214_400) — using the binary form leaks the 25.0–26.2 MB range to the API
|
||||
# as 413s. Long audio (~70+ min Opus) keeps the attachment but skips transcription.
|
||||
WHISPER_BYTE_LIMIT = 25_000_000
|
||||
|
||||
attr_reader :attachment, :message, :account
|
||||
|
||||
@@ -43,7 +42,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
blob = attachment.file&.blob
|
||||
return false unless blob
|
||||
|
||||
blob.byte_size > TRANSCRIPTION_BYTE_LIMIT
|
||||
blob.byte_size > WHISPER_BYTE_LIMIT
|
||||
end
|
||||
|
||||
def fetch_audio_file
|
||||
@@ -76,12 +75,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
transcribed_text = nil
|
||||
|
||||
File.open(temp_file_path, 'rb') do |file|
|
||||
# temperature: 0.0 minimises hallucinations on silence / near-silent
|
||||
# audio; non-zero values trigger spiraling repeats — well-documented
|
||||
# behaviour across OpenAI transcription models.
|
||||
# temperature: 0.0 minimises Whisper's hallucinations on silence /
|
||||
# near-silent audio; non-zero values trigger spiraling repeats like
|
||||
# "Oh, dear. Oh, dear. Oh, dear." — well-documented Whisper behaviour.
|
||||
response = @client.audio.transcribe(
|
||||
parameters: {
|
||||
model: TRANSCRIPTION_MODEL,
|
||||
model: WHISPER_MODEL,
|
||||
file: file,
|
||||
temperature: 0.0
|
||||
}
|
||||
@@ -98,7 +97,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
def instrumentation_params(file_path)
|
||||
{
|
||||
span_name: 'llm.messages.audio_transcription',
|
||||
model: TRANSCRIPTION_MODEL,
|
||||
model: WHISPER_MODEL,
|
||||
account_id: account&.id,
|
||||
feature_name: 'audio_transcription',
|
||||
file_path: file_path
|
||||
|
||||
@@ -59,17 +59,9 @@ class Voice::InboundCallBuilder
|
||||
end
|
||||
|
||||
def ensure_contact!
|
||||
contact = account.contacts.find_or_create_by!(phone_number: from_number) do |record|
|
||||
record.name = contact_name.presence || from_number
|
||||
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
|
||||
record.name = from_number if record.name.blank?
|
||||
end
|
||||
contact.update!(name: contact_name) if contact_name.present? && contact.name == from_number
|
||||
contact
|
||||
end
|
||||
|
||||
# WhatsApp inbound calls carry the caller's profile name in extra_meta; Twilio
|
||||
# calls don't, so contact naming falls back to the phone number.
|
||||
def contact_name
|
||||
extra_meta['contact_name'].presence
|
||||
end
|
||||
|
||||
# WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +.
|
||||
|
||||
@@ -73,27 +73,15 @@ class Whatsapp::IncomingCallService
|
||||
|
||||
def create_inbound_call(payload)
|
||||
sdp_offer = payload.dig(:session, :sdp)
|
||||
extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
|
||||
name = caller_profile_name(payload)
|
||||
extra_meta['contact_name'] = name if name.present?
|
||||
|
||||
call = Voice::InboundCallBuilder.perform!(
|
||||
inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
|
||||
provider: :whatsapp, extra_meta: extra_meta
|
||||
provider: :whatsapp,
|
||||
extra_meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
|
||||
)
|
||||
update_conversation(call)
|
||||
broadcast_incoming(call, sdp_offer)
|
||||
end
|
||||
|
||||
# Match strictly on wa_id (== calls[].from): in a batched payload missing this
|
||||
# call's contact entry, borrowing another caller's name would corrupt this
|
||||
# contact, so fall back to the phone number (nil here) instead of contacts.first.
|
||||
def caller_profile_name(payload)
|
||||
contacts = Array(params[:contacts]).map(&:with_indifferent_access)
|
||||
match = contacts.find { |c| c[:wa_id].to_s == payload[:from].to_s }
|
||||
match&.dig(:profile, :name).presence
|
||||
end
|
||||
|
||||
# `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply
|
||||
# Meta's SDP answer so the handshake completes during ringing; the call
|
||||
# stays in `ringing` until status=ACCEPTED arrives. Don't gate on
|
||||
@@ -171,13 +159,11 @@ class Whatsapp::IncomingCallService
|
||||
)
|
||||
end
|
||||
|
||||
# Ring the assignee if any, else online inbox agents, else fall back to the
|
||||
# inbox's own agents and account admins. Never the whole-account stream, which
|
||||
# would ring online agents from unrelated inboxes.
|
||||
# Ring the assignee if any, else online inbox agents, else the whole account.
|
||||
def broadcast_incoming(call, sdp_offer)
|
||||
contact = call.contact
|
||||
token = call.conversation.assignee&.pubsub_token
|
||||
streams = token ? [token] : (online_agent_streams.presence || fallback_agent_streams)
|
||||
streams = token ? [token] : (online_agent_streams.presence || account_streams)
|
||||
broadcast(call, 'voice_call.incoming',
|
||||
streams: streams,
|
||||
direction: call.direction_label, inbox_id: call.inbox_id,
|
||||
@@ -189,11 +175,6 @@ class Whatsapp::IncomingCallService
|
||||
inbox.available_agents.pluck('users.pubsub_token').compact
|
||||
end
|
||||
|
||||
def fallback_agent_streams
|
||||
user_ids = inbox.member_ids | inbox.account.administrators.ids
|
||||
User.where(id: user_ids).pluck(:pubsub_token).compact
|
||||
end
|
||||
|
||||
def broadcast(call, event, streams: account_streams, **extra)
|
||||
payload = { event: event, data: base_payload(call).merge(extra) }
|
||||
streams.each { |s| ActionCable.server.broadcast(s, payload) }
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.14.1",
|
||||
"version": "4.14.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -140,45 +140,6 @@ describe Messages::Facebook::MessageBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
[
|
||||
{
|
||||
source_id: 'm_fallback_test',
|
||||
attachment: { type: 'fallback', title: 'Shared link', url: 'https://www.example.com/shared-link' },
|
||||
title: 'Shared link',
|
||||
url: 'https://www.example.com/shared-link'
|
||||
},
|
||||
{
|
||||
source_id: 'm_share_test',
|
||||
attachment: { type: 'share', title: 'Shared Facebook post', payload: { url: 'https://www.facebook.com/example/posts/123' } },
|
||||
title: 'Shared Facebook post',
|
||||
url: 'https://www.facebook.com/example/posts/123'
|
||||
}
|
||||
].each do |message_data|
|
||||
it "stores #{message_data[:attachment][:type]} attachments as fallback links" do
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
{ first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access
|
||||
)
|
||||
expect(Down).not_to receive(:download)
|
||||
|
||||
message_object = {
|
||||
messaging: {
|
||||
sender: { id: '3383290475046708' },
|
||||
recipient: { id: facebook_channel.page_id },
|
||||
message: { mid: message_data[:source_id], attachments: [message_data[:attachment]] }
|
||||
}
|
||||
}.to_json
|
||||
message = Integrations::Facebook::MessageParser.new(message_object)
|
||||
|
||||
described_class.new(message, facebook_channel.inbox).perform
|
||||
|
||||
attachment = facebook_channel.inbox.messages.find_by(source_id: message_data[:source_id]).attachments.first
|
||||
expect(attachment.file_type).to eq('fallback')
|
||||
expect(attachment.fallback_title).to eq(message_data[:title])
|
||||
expect(attachment.external_url).to eq(message_data[:url])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock to single conversation' do
|
||||
subject(:mocked_message_builder) do
|
||||
described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Onboarding API', type: :request do
|
||||
let(:account) { create(:account, domain: 'example.com') }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/onboarding' do
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding", params: { website: 'acme.com' }, as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as an agent (non-admin)' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized and does not change the account' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { name: 'Hijacked', website: 'attacker.com' },
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(account.reload.name).not_to eq('Hijacked')
|
||||
end
|
||||
|
||||
it 'does not create a help center portal' do
|
||||
account.update!(custom_attributes: { 'onboarding_step' => 'account_details' })
|
||||
|
||||
expect do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'attacker.com' },
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
end.not_to change(account.portals, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when finalizing account_details' do
|
||||
before { account.update!(custom_attributes: { 'onboarding_step' => 'account_details' }) }
|
||||
|
||||
it 'saves name and locale' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { name: 'Acme Inc', locale: 'fr' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(account.reload.name).to eq('Acme Inc')
|
||||
expect(account.locale).to eq('fr')
|
||||
end
|
||||
|
||||
it 'merges custom_attributes' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com', industry: 'tech', company_size: '10-50' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
attrs = account.reload.custom_attributes
|
||||
expect(attrs['website']).to eq('acme.com')
|
||||
expect(attrs['industry']).to eq('tech')
|
||||
expect(attrs['company_size']).to eq('10-50')
|
||||
end
|
||||
|
||||
it 'clears onboarding_step' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
|
||||
end
|
||||
|
||||
it 'invokes HelpCenterCreationService when website is present', skip: 'help center generation wiring disabled until UI is ready' do
|
||||
service = instance_double(Onboarding::HelpCenterCreationService, perform: nil)
|
||||
allow(Onboarding::HelpCenterCreationService).to receive(:new).and_return(service)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(Onboarding::HelpCenterCreationService).to have_received(:new) do |arg_account, arg_user|
|
||||
expect(arg_account.id).to eq(account.id)
|
||||
expect(arg_user.id).to eq(admin.id)
|
||||
end
|
||||
expect(service).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'does not create a help center portal when website is blank' do
|
||||
expect do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { name: 'Acme Inc' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.not_to change(account.portals, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when onboarding_step is not account_details' do
|
||||
before { account.update!(custom_attributes: { 'onboarding_step' => 'invite_team' }) }
|
||||
|
||||
it 'does not clear onboarding_step' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
|
||||
end
|
||||
|
||||
it 'does not create a help center portal' do
|
||||
expect do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.not_to change(account.portals, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -302,6 +302,16 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
|
||||
end
|
||||
|
||||
it 'clears onboarding step when current value is account_details' do
|
||||
account.update(custom_attributes: { onboarding_step: 'account_details' })
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
|
||||
end
|
||||
|
||||
it 'will not update onboarding step if onboarding step is not present in account custom attributes' do
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
|
||||
@@ -163,156 +163,4 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do
|
||||
expect(response).to redirect_to('/frontend/app/login?error=access-denied')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'session limit enforcement' do
|
||||
let(:user) { create(:user, password: 'Test@123456') }
|
||||
let(:browser_ua) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15' }
|
||||
let(:mobile_ua) { 'okhttp/4.9.3' }
|
||||
|
||||
def seed_token(client_id, expiry_offset_days: 30, with_session: true)
|
||||
user.tokens = user.tokens.merge(
|
||||
client_id => { 'token' => 'x', 'expiry' => (Time.current + expiry_offset_days.days).to_i }
|
||||
)
|
||||
user.save!
|
||||
user.user_sessions.create!(client_id: client_id, last_activity_at: Time.current) if with_session
|
||||
end
|
||||
|
||||
def login_params
|
||||
{ email: user.email, password: 'Test@123456' }
|
||||
end
|
||||
|
||||
context 'when under the limit' do
|
||||
it 'allows login without intervention' do
|
||||
request.env['HTTP_USER_AGENT'] = browser_ua
|
||||
3.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
|
||||
|
||||
post :create, params: login_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when at the limit from a browser with full tracking' do
|
||||
before do
|
||||
request.env['HTTP_USER_AGENT'] = browser_ua
|
||||
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
|
||||
end
|
||||
|
||||
it 'returns 409 with the session list (picker)' do
|
||||
post :create, params: login_params
|
||||
|
||||
expect(response).to have_http_status(:conflict)
|
||||
body = response.parsed_body
|
||||
expect(body['sessions_limit_reached']).to be true
|
||||
expect(body['sessions'].size).to eq(5)
|
||||
end
|
||||
|
||||
it 'does not create a new session row' do
|
||||
expect { post :create, params: login_params }.not_to change(user.user_sessions, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when at the limit from a non-browser client' do
|
||||
before do
|
||||
request.env['HTTP_USER_AGENT'] = mobile_ua
|
||||
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30 + i, with_session: false) }
|
||||
end
|
||||
|
||||
it 'silently evicts the oldest token and lets login proceed' do
|
||||
post :create, params: login_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(user.reload.tokens.keys).not_to include('c0')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when at the limit but tracking is partial (legacy tokens present)' do
|
||||
before do
|
||||
request.env['HTTP_USER_AGENT'] = browser_ua
|
||||
# one tracked, four legacy (no user_session rows)
|
||||
seed_token('tracked', expiry_offset_days: 60, with_session: true)
|
||||
4.times { |i| seed_token("legacy#{i}", expiry_offset_days: 10 + i, with_session: false) }
|
||||
end
|
||||
|
||||
it 'silent-evicts instead of showing a partial picker' do
|
||||
post :create, params: login_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'drops an untracked token first, keeping the tracked session alive' do
|
||||
post :create, params: login_params
|
||||
|
||||
tokens = user.reload.tokens.keys
|
||||
expect(tokens).to include('tracked')
|
||||
# legacy0 expires soonest -> evict_oldest_token picks it
|
||||
expect(tokens).not_to include('legacy0')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when at the limit with full tracking (no legacy gap)' do
|
||||
before do
|
||||
request.env['HTTP_USER_AGENT'] = mobile_ua
|
||||
# Five tracked sessions, varying activity timestamps
|
||||
5.times do |i|
|
||||
seed_token("tracked#{i}", expiry_offset_days: 30)
|
||||
user.user_sessions.find_by(client_id: "tracked#{i}").update!(last_activity_at: (5 - i).days.ago)
|
||||
end
|
||||
end
|
||||
|
||||
it 'evicts the oldest tracked session by last_activity_at' do
|
||||
post :create, params: login_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
# tracked0 had the oldest last_activity_at (5 days ago)
|
||||
expect(user.reload.tokens.keys).not_to include('tracked0')
|
||||
expect(user.user_sessions.exists?(client_id: 'tracked0')).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'with revoke_session_id during login' do
|
||||
before do
|
||||
request.env['HTTP_USER_AGENT'] = browser_ua
|
||||
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
|
||||
end
|
||||
|
||||
it 'revokes the chosen session and proceeds with login' do
|
||||
target = user.user_sessions.find_by(client_id: 'c2')
|
||||
|
||||
post :create, params: login_params.merge(revoke_session_id: target.id)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(user.reload.tokens.keys).not_to include('c2')
|
||||
expect(user.user_sessions.exists?(id: target.id)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'with revoke_all_sessions during login' do
|
||||
before do
|
||||
request.env['HTTP_USER_AGENT'] = browser_ua
|
||||
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
|
||||
end
|
||||
|
||||
it 'wipes all sessions and tokens, then proceeds with login' do
|
||||
post :create, params: login_params.merge(revoke_all_sessions: true)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(user.reload.tokens.keys).not_to include('c0', 'c1', 'c2', 'c3', 'c4')
|
||||
# the new login adds one fresh token
|
||||
expect(user.tokens.keys.size).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a successful login' do
|
||||
before { request.env['HTTP_USER_AGENT'] = browser_ua }
|
||||
|
||||
it 'creates a UserSession row for the new client_id' do
|
||||
expect { post :create, params: login_params }.to change(user.user_sessions, :count).by(1)
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.browser_name).to eq('Safari')
|
||||
expect(session.platform_name).to eq('macOS')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -51,13 +51,10 @@ RSpec.describe 'Enterprise Audit API', type: :request do
|
||||
expect(json_response['audit_logs'][1]['action']).to eql('create')
|
||||
expect(json_response['audit_logs'][1]['audited_changes']['name']).to eql(inbox.name)
|
||||
expect(json_response['audit_logs'][1]['associated_id']).to eql(account.id)
|
||||
expect(json_response['current_page']).to be(1)
|
||||
# contains audit log for account user as well
|
||||
# contains audit logs for account update(enable audit logs)
|
||||
expect(json_response.slice('current_page', 'per_page', 'total_entries')).to eql(
|
||||
'current_page' => 1,
|
||||
'per_page' => 25,
|
||||
'total_entries' => 3
|
||||
)
|
||||
expect(json_response['total_entries']).to be(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -147,7 +147,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
params: sync_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).on_queue('low').exactly(documents.size).times
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).exactly(documents.size).times
|
||||
|
||||
documents.each do |document|
|
||||
expect(document.reload).to have_attributes(
|
||||
@@ -190,7 +190,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'queues documents that already have a sync in progress' do
|
||||
it 'skips documents that already have a sync in progress' do
|
||||
syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
syncing_document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
|
||||
|
||||
@@ -199,10 +199,9 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
params: sync_params.merge(ids: [syncing_document.id]),
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(syncing_document).on_queue('low')
|
||||
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to eq({ ids: [syncing_document.id], count: 1 })
|
||||
end
|
||||
|
||||
it 'queues stale syncing documents again' do
|
||||
|
||||
@@ -243,7 +243,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
before do
|
||||
create_list(:captain_document, 5, assistant: assistant, account: account)
|
||||
|
||||
InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').update!(value: captain_limits.to_json)
|
||||
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
params: valid_attributes,
|
||||
headers: admin.create_new_auth_token
|
||||
@@ -281,7 +281,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document).on_queue('low')
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
sync_status: 'syncing',
|
||||
@@ -292,15 +292,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
expect(response).to have_http_status(:accepted)
|
||||
end
|
||||
|
||||
it 'queues documents that already have a sync in progress' do
|
||||
it 'rejects documents that already have a sync in progress' do
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document).on_queue('low')
|
||||
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
|
||||
expect(response).to have_http_status(:accepted)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'queues stale syncing documents again' do
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Documents::PerformSyncJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:document) { create(:captain_document, assistant: assistant, account: account, status: :available) }
|
||||
|
||||
def stub_lock(job)
|
||||
allow(job).to receive(:with_lock).and_yield
|
||||
end
|
||||
|
||||
def stub_page_fetch(content: 'Updated content')
|
||||
fetch_result = Captain::Documents::SinglePageFetcher::Result.new(
|
||||
success: true,
|
||||
title: 'Updated title',
|
||||
content: content
|
||||
)
|
||||
fetcher = instance_double(Captain::Documents::SinglePageFetcher, fetch: fetch_result)
|
||||
allow(Captain::Documents::SinglePageFetcher).to receive(:new).and_return(fetcher)
|
||||
end
|
||||
|
||||
def stub_page_fetch_failure
|
||||
fetcher = instance_double(Captain::Documents::SinglePageFetcher)
|
||||
allow(fetcher).to receive(:fetch).and_raise(StandardError, 'boom')
|
||||
allow(Captain::Documents::SinglePageFetcher).to receive(:new).and_return(fetcher)
|
||||
end
|
||||
|
||||
it 'syncs the document content' do
|
||||
travel_to Time.zone.local(2026, 5, 18, 10, 0, 0) do
|
||||
job = described_class.new
|
||||
stub_lock(job)
|
||||
stub_page_fetch
|
||||
|
||||
job.perform(document)
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
sync_status: 'synced',
|
||||
last_sync_attempted_at: Time.current,
|
||||
last_synced_at: Time.current,
|
||||
content: 'Updated content'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'marks unexpected failures as failed' do
|
||||
travel_to Time.zone.local(2026, 5, 18, 10, 0, 0) do
|
||||
job = described_class.new
|
||||
stub_lock(job)
|
||||
stub_page_fetch_failure
|
||||
|
||||
expect { job.perform(document) }.to raise_error(StandardError, 'boom')
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
sync_status: 'failed',
|
||||
last_sync_error_code: 'sync_error',
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,28 +5,11 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
before do
|
||||
set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', { business: 168, enterprise: 24, startups: 720, hacker: nil }.to_json)
|
||||
set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 50)
|
||||
set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', 1000)
|
||||
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24, hacker: nil }.to_json)
|
||||
account.enable_features!('captain_document_auto_sync')
|
||||
clear_enqueued_jobs
|
||||
end
|
||||
|
||||
def set_installation_config(name, value)
|
||||
InstallationConfig.find_or_initialize_by(name: name).tap do |config|
|
||||
config.value = value
|
||||
config.save!
|
||||
end
|
||||
end
|
||||
|
||||
def update_sync_limit(name, value)
|
||||
InstallationConfig.find_by!(name: name).update!(value: value)
|
||||
end
|
||||
|
||||
def sync_job_for(document)
|
||||
have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
end
|
||||
|
||||
context 'when the account has not enabled auto-sync' do
|
||||
before { account.disable_features!('captain_document_auto_sync') }
|
||||
|
||||
@@ -49,25 +32,6 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a plan name is passed' do
|
||||
it 'queues due documents only for that plan' do
|
||||
enterprise_account = create(:account, custom_attributes: { plan_name: 'Enterprise' })
|
||||
enterprise_account.enable_features!('captain_document_auto_sync')
|
||||
enterprise_assistant = create(:captain_assistant, account: enterprise_account)
|
||||
business_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
enterprise_document = create(:captain_document, assistant: enterprise_assistant, account: enterprise_account, status: :available)
|
||||
|
||||
business_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
|
||||
enterprise_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
described_class.new.perform('enterprise')
|
||||
|
||||
expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(enterprise_document)
|
||||
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(business_document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an available document has backfilled sync metadata' do
|
||||
it 'leaves it alone when last synced within the plan cadence' do
|
||||
create(
|
||||
@@ -90,34 +54,53 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 8.days.ago
|
||||
last_synced_at: 3.days.ago
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to sync_job_for(document).on_queue('purgable')
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
end
|
||||
|
||||
it 'delays only the queued sync job' do
|
||||
it 'marks the due document as syncing before queueing' do
|
||||
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
|
||||
job = described_class.new
|
||||
allow(job).to receive(:rand).and_return(30.minutes.to_i)
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 8.days.ago
|
||||
last_synced_at: 3.days.ago
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
job.perform
|
||||
described_class.new.perform
|
||||
|
||||
expect(Captain::Documents::PerformSyncJob)
|
||||
.to have_been_enqueued.with(document).at(30.minutes.from_now)
|
||||
expect(document.reload).to have_attributes(
|
||||
sync_status: 'syncing',
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not queue the same document again while the reserved sync is fresh' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 2.days.ago
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an available document was synced within the plan cadence' do
|
||||
@@ -133,59 +116,78 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
context 'when an available document was last synced before the plan cadence' do
|
||||
it 'queues a sync for that document' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago)
|
||||
document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to sync_job_for(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when jitter spreads queued sync execution' do
|
||||
it 'uses a widened due window so jittered syncs do not skip the next plan run' do
|
||||
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
|
||||
job = described_class.new
|
||||
interval = 1.week
|
||||
due_window = (interval.to_i / 2).seconds
|
||||
allow(job).to receive(:rand).and_return(2.hours.to_i)
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
|
||||
document.update!(sync_status: :synced, last_synced_at: (due_window - 1.minute).ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { job.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
|
||||
document.update!(sync_status: :synced, last_synced_at: (due_window + 1.minute).ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { job.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
.with(document)
|
||||
.on_queue('purgable')
|
||||
.at(2.hours.from_now)
|
||||
end
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
end
|
||||
|
||||
it 'uses a random delay inside the cadence window' do
|
||||
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
document.update!(sync_status: :synced, last_synced_at: 8.days.ago)
|
||||
job = described_class.new
|
||||
sync_execution_delay = 12_345.seconds
|
||||
it 'skips invalid legacy documents without counting them against the account cap' do
|
||||
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 1)
|
||||
create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :in_progress,
|
||||
content: nil,
|
||||
external_link: 'https://example.com'
|
||||
)
|
||||
invalid_document = build(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 2.days.ago,
|
||||
last_sync_attempted_at: 2.days.ago,
|
||||
external_link: 'https://example.com/'
|
||||
)
|
||||
invalid_document.save!(validate: false)
|
||||
valid_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
valid_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
clear_enqueued_jobs
|
||||
allow(job).to receive(:rand).with(0..described_class::WEEKLY_SYNC_JITTER.to_i).and_return(sync_execution_delay.to_i)
|
||||
expect { described_class.new.perform }.not_to raise_error
|
||||
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(invalid_document)
|
||||
expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(valid_document)
|
||||
end
|
||||
|
||||
expect { job.perform }
|
||||
.to sync_job_for(document)
|
||||
.at(sync_execution_delay.from_now)
|
||||
end
|
||||
it 'keeps paging due documents when invalid documents fill the first batch' do
|
||||
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 1)
|
||||
stub_const("#{described_class}::DUE_DOCUMENT_BATCH_SIZE", 1)
|
||||
create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :in_progress,
|
||||
content: nil,
|
||||
external_link: 'https://example.com'
|
||||
)
|
||||
invalid_document = build(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 2.days.ago,
|
||||
last_sync_attempted_at: 3.days.ago,
|
||||
external_link: 'https://example.com/'
|
||||
)
|
||||
invalid_document.save!(validate: false)
|
||||
valid_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
valid_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
described_class.new.perform
|
||||
|
||||
expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(valid_document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when more documents are due than the account cap allows' do
|
||||
before do
|
||||
update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 2)
|
||||
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 2)
|
||||
end
|
||||
|
||||
it 'queues backfilled and oldest-attempted documents first' do
|
||||
@@ -193,47 +195,26 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
oldest_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
backfilled_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
|
||||
newest_document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago)
|
||||
oldest_document.update!(sync_status: :synced, last_synced_at: 9.days.ago, last_sync_attempted_at: 9.days.ago)
|
||||
backfilled_document.update!(sync_status: :synced, last_synced_at: 10.days.ago, last_sync_attempted_at: nil)
|
||||
newest_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
|
||||
oldest_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
|
||||
backfilled_document.update!(sync_status: :synced, last_synced_at: 4.days.ago, last_sync_attempted_at: nil)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to sync_job_for(backfilled_document)
|
||||
.and sync_job_for(oldest_document)
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(backfilled_document)
|
||||
.and have_enqueued_job(Captain::Documents::PerformSyncJob).with(oldest_document)
|
||||
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(newest_document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when sync caps are configured' do
|
||||
it 'uses installation config caps for per-account and global limits' do
|
||||
update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 2)
|
||||
update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', 3)
|
||||
|
||||
second_account = create(:account, custom_attributes: { plan_name: 'business' })
|
||||
second_account.enable_features!('captain_document_auto_sync')
|
||||
second_assistant = create(:captain_assistant, account: second_account)
|
||||
|
||||
first_account_documents = create_list(:captain_document, 3, assistant: assistant, account: account, status: :available)
|
||||
second_account_documents = create_list(:captain_document, 3, assistant: second_assistant, account: second_account, status: :available)
|
||||
(first_account_documents + second_account_documents).each do |document|
|
||||
document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago)
|
||||
end
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).exactly(3).times
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an available document failed before the plan cadence' do
|
||||
it 'queues a sync for that document' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
document.update!(sync_status: :failed, last_sync_attempted_at: 8.days.ago)
|
||||
document.update!(sync_status: :failed, last_sync_attempted_at: 2.days.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to sync_job_for(document)
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -247,7 +228,7 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to sync_job_for(document)
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Internal::TriggerDailyScheduledItemsJob do
|
||||
before do
|
||||
allow(ChatwootHub).to receive(:installation_identifier).and_return('test-installation-id')
|
||||
allow(Captain::Documents::ScheduleSyncsJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
it 'enqueues enterprise Captain document auto-sync every day' do
|
||||
travel_to Time.zone.parse('2026-05-26 00:00:00 UTC') do
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('enterprise')
|
||||
end
|
||||
|
||||
it 'enqueues business Captain document auto-sync weekly' do
|
||||
travel_to Time.zone.parse('2026-05-24 00:00:00 UTC') do
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('business')
|
||||
end
|
||||
|
||||
it 'enqueues startup Captain document auto-sync monthly' do
|
||||
travel_to Time.zone.parse('2026-06-01 00:00:00 UTC') do
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('startups')
|
||||
end
|
||||
|
||||
it 'does not enqueue business or startup Captain document auto-sync before their plan window' do
|
||||
travel_to Time.zone.parse('2026-05-25 00:00:00 UTC') do
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('enterprise')
|
||||
expect(Captain::Documents::ScheduleSyncsJob).not_to have_received(:perform_later).with('business')
|
||||
expect(Captain::Documents::ScheduleSyncsJob).not_to have_received(:perform_later).with('startups')
|
||||
end
|
||||
end
|
||||
@@ -1,26 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise::ContactPolicy', type: :policy do
|
||||
subject(:contact_policy) { ContactPolicy }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['contact_manage']) }
|
||||
let(:agent) { create(:user) }
|
||||
let(:account_user) { create(:account_user, user: agent, account: account, role: :agent, custom_role: custom_role) }
|
||||
let(:agent_context) { { user: agent, account: account, account_user: account_user } }
|
||||
|
||||
permissions :export? do
|
||||
context 'when agent has contact_manage permission' do
|
||||
it { expect(contact_policy).to permit(agent_context, contact) }
|
||||
end
|
||||
end
|
||||
|
||||
permissions :import? do
|
||||
context 'when agent has contact_manage permission' do
|
||||
it { expect(contact_policy).to permit(agent_context, contact) }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -72,7 +72,7 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
|
||||
content_type: 'audio/mpeg'
|
||||
)
|
||||
allow(service).to receive(:can_transcribe?).and_return(true)
|
||||
allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::TRANSCRIPTION_BYTE_LIMIT + 1)
|
||||
allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::WHISPER_BYTE_LIMIT + 1)
|
||||
end
|
||||
|
||||
it 'returns an error without calling Whisper' do
|
||||
|
||||
@@ -32,9 +32,6 @@ describe Whatsapp::IncomingCallService do
|
||||
|
||||
describe 'inbound connect' do
|
||||
let(:sdp_offer) { "v=0\r\n...sdp..." }
|
||||
let!(:agent) { create(:user, account: account) }
|
||||
|
||||
before { create(:inbox_member, inbox: inbox, user: agent) }
|
||||
|
||||
it 'creates the Call + Conversation + voice_call message and broadcasts voice_call.incoming' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
@@ -47,15 +44,9 @@ describe Whatsapp::IncomingCallService do
|
||||
expect(call).to have_attributes(provider: 'whatsapp', direction: 'incoming', status: 'ringing',
|
||||
provider_call_id: provider_call_id)
|
||||
expect(call.meta['sdp_offer']).to eq(sdp_offer)
|
||||
# No agent is online, so the call falls back to the inbox's agents (and
|
||||
# account admins) — never the whole-account stream.
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
agent.pubsub_token,
|
||||
hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer))
|
||||
)
|
||||
expect(ActionCable.server).not_to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
hash_including(event: 'voice_call.incoming')
|
||||
hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -40,13 +40,5 @@ RSpec.describe TriggerScheduledItemsJob do
|
||||
expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'does not trigger campaigns that are already processing' do
|
||||
create(:campaign, inbox: twilio_inbox, account: account, campaign_status: :processing)
|
||||
|
||||
expect(Campaigns::TriggerOneoffCampaignJob).not_to receive(:perform_later)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -83,38 +83,6 @@ RSpec.describe Campaign do
|
||||
campaign.save!
|
||||
campaign.trigger!
|
||||
end
|
||||
|
||||
it 'marks the campaign as processing before triggering the service' do
|
||||
campaign.save!
|
||||
sms_service = double
|
||||
|
||||
expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service)
|
||||
expect(sms_service).to receive(:perform) do
|
||||
expect(campaign.reload.processing?).to be true
|
||||
end
|
||||
|
||||
campaign.trigger!
|
||||
end
|
||||
|
||||
it 'does not trigger a processing campaign again' do
|
||||
campaign.save!
|
||||
campaign.processing!
|
||||
|
||||
expect(Twilio::OneoffSmsCampaignService).not_to receive(:new)
|
||||
|
||||
campaign.trigger!
|
||||
end
|
||||
|
||||
it 'keeps the campaign processing when triggering fails' do
|
||||
campaign.save!
|
||||
sms_service = double
|
||||
|
||||
expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service)
|
||||
expect(sms_service).to receive(:perform).and_raise(StandardError, 'provider error')
|
||||
|
||||
expect { campaign.trigger! }.to raise_error(StandardError, 'provider error')
|
||||
expect(campaign.reload.processing?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when SMS campaign' do
|
||||
@@ -139,22 +107,6 @@ RSpec.describe Campaign do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when WhatsApp campaign feature is disabled' do
|
||||
let(:account) { create(:account) }
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:campaign) { create(:campaign, account: account, inbox: whatsapp_channel.inbox) }
|
||||
|
||||
it 'does not mark the campaign as processing' do
|
||||
expect(Whatsapp::OneoffCampaignService).not_to receive(:new)
|
||||
|
||||
campaign.trigger!
|
||||
|
||||
expect(campaign.reload.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Website campaign' do
|
||||
let(:campaign) { build(:campaign) }
|
||||
|
||||
|
||||
@@ -223,12 +223,12 @@ RSpec.describe Channel::Whatsapp do
|
||||
expect(channel.voice_enabled?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for manual whatsapp_cloud channels with calling_enabled' do
|
||||
it 'returns false for whatsapp_cloud channels without embedded_signup source' do
|
||||
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
channel.update!(provider_config: channel.provider_config.merge('source' => 'manual', 'calling_enabled' => true))
|
||||
|
||||
expect(channel.voice_enabled?).to be true
|
||||
expect(channel.voice_enabled?).to be false
|
||||
end
|
||||
|
||||
it 'returns false for default-provider channels (360dialog) even with calling_enabled' do
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe UserSession do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:user) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
subject { described_class.new(user: user, client_id: 'abc') }
|
||||
|
||||
it { is_expected.to validate_presence_of(:client_id) }
|
||||
|
||||
it 'validates uniqueness of client_id scoped to user_id' do
|
||||
described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current)
|
||||
|
||||
duplicate = described_class.new(user: user, client_id: 'abc')
|
||||
expect(duplicate).not_to be_valid
|
||||
expect(duplicate.errors[:client_id]).to be_present
|
||||
end
|
||||
|
||||
it 'allows the same client_id for different users' do
|
||||
other = create(:user)
|
||||
described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current)
|
||||
|
||||
expect(described_class.new(user: other, client_id: 'abc', last_activity_at: Time.current)).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe '#current?' do
|
||||
let(:session) { described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current) }
|
||||
|
||||
it 'returns true when client_id matches' do
|
||||
expect(session.current?('abc')).to be true
|
||||
end
|
||||
|
||||
it 'returns false when client_id differs' do
|
||||
expect(session.current?('xyz')).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#should_update_activity?' do
|
||||
let(:session) { described_class.new(user: user, client_id: 'abc') }
|
||||
|
||||
it 'returns true when last_activity_at is nil' do
|
||||
session.last_activity_at = nil
|
||||
expect(session.should_update_activity?).to be true
|
||||
end
|
||||
|
||||
it 'returns true when last_activity_at is older than the throttle window' do
|
||||
session.last_activity_at = 10.minutes.ago
|
||||
expect(session.should_update_activity?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when last_activity_at is within the throttle window' do
|
||||
session.last_activity_at = 1.minute.ago
|
||||
expect(session.should_update_activity?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -254,37 +254,4 @@ RSpec.describe User do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sync_user_sessions callback' do
|
||||
let(:user_with_tokens) do
|
||||
u = create(:user)
|
||||
u.tokens = {
|
||||
'client-a' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i },
|
||||
'client-b' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i }
|
||||
}
|
||||
u.save!
|
||||
u.user_sessions.create!(client_id: 'client-a', last_activity_at: Time.current)
|
||||
u.user_sessions.create!(client_id: 'client-b', last_activity_at: Time.current)
|
||||
u
|
||||
end
|
||||
|
||||
it 'destroys user_sessions whose client_id is no longer in tokens' do
|
||||
user_with_tokens.tokens = user_with_tokens.tokens.except('client-a')
|
||||
|
||||
expect { user_with_tokens.save! }.to change(user_with_tokens.user_sessions, :count).by(-1)
|
||||
expect(user_with_tokens.user_sessions.pluck(:client_id)).to eq(['client-b'])
|
||||
end
|
||||
|
||||
it 'leaves user_sessions alone when tokens did not change' do
|
||||
user_with_tokens.update!(name: 'New Name')
|
||||
|
||||
expect(user_with_tokens.user_sessions.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'destroys all user_sessions when tokens is cleared' do
|
||||
user_with_tokens.tokens = {}
|
||||
|
||||
expect { user_with_tokens.save! }.to change(user_with_tokens.user_sessions, :count).by(-2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Profile Sessions API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:auth_headers) { user.create_new_auth_token }
|
||||
let(:current_client_id) { auth_headers['client'] }
|
||||
|
||||
describe 'GET /api/v1/profile/sessions' do
|
||||
it 'returns 401 without auth' do
|
||||
get '/api/v1/profile/sessions', as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns the current user sessions ordered by last_activity_at desc' do
|
||||
older = user.user_sessions.create!(client_id: current_client_id, browser_name: 'Chrome', last_activity_at: 2.days.ago)
|
||||
newer = user.user_sessions.create!(client_id: 'other-client', browser_name: 'Firefox', last_activity_at: 1.hour.ago)
|
||||
|
||||
get '/api/v1/profile/sessions', headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
sessions = response.parsed_body
|
||||
expect(sessions.map { |s| s['id'] }).to eq([newer.id, older.id])
|
||||
expect(sessions.find { |s| s['id'] == older.id }['current']).to be true
|
||||
expect(sessions.find { |s| s['id'] == newer.id }['current']).to be false
|
||||
end
|
||||
|
||||
it 'returns an empty array when no sessions exist' do
|
||||
get '/api/v1/profile/sessions', headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/profile/sessions/:id' do
|
||||
let!(:other_session) { user.user_sessions.create!(client_id: 'other-client', last_activity_at: 1.hour.ago) }
|
||||
|
||||
before do
|
||||
# Seed tokens hash so revoke can clean it up
|
||||
user.tokens = user.tokens.merge('other-client' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i })
|
||||
user.save!
|
||||
end
|
||||
|
||||
it 'destroys the session and removes its token entry' do
|
||||
expect do
|
||||
delete "/api/v1/profile/sessions/#{other_session.id}", headers: auth_headers, as: :json
|
||||
end.to change(user.user_sessions, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(user.reload.tokens.keys).not_to include('other-client')
|
||||
end
|
||||
|
||||
it 'returns 422 when trying to revoke the current session' do
|
||||
current = user.user_sessions.create!(client_id: current_client_id, last_activity_at: Time.current)
|
||||
|
||||
delete "/api/v1/profile/sessions/#{current.id}", headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to be_present
|
||||
expect(user.user_sessions.exists?(id: current.id)).to be true
|
||||
end
|
||||
|
||||
it 'returns 404 for a nonexistent session id' do
|
||||
delete '/api/v1/profile/sessions/9999999', headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'does not allow revoking another user' do
|
||||
other_user = create(:user, account: account)
|
||||
foreign = other_user.user_sessions.create!(client_id: 'foreign', last_activity_at: 1.hour.ago)
|
||||
|
||||
delete "/api/v1/profile/sessions/#{foreign.id}", headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(other_user.user_sessions.exists?(id: foreign.id)).to be true
|
||||
end
|
||||
|
||||
it 'returns 401 without auth' do
|
||||
delete "/api/v1/profile/sessions/#{other_session.id}", as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -45,19 +45,6 @@ describe Sms::OneoffSmsCampaignService do
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'marks the campaign completed after processing the audience' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
expect(sms_channel).to receive(:send_text_message) do
|
||||
expect(campaign.reload.completed?).to be false
|
||||
end
|
||||
|
||||
sms_campaign_service.perform
|
||||
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'uses liquid template service to process campaign message' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
@@ -61,24 +61,6 @@ describe Twilio::OneoffSmsCampaignService do
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'marks the campaign completed after processing the audience' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
expect(twilio_messages).to receive(:create).with(
|
||||
body: campaign.message,
|
||||
messaging_service_sid: twilio_sms.messaging_service_sid,
|
||||
to: contact.phone_number,
|
||||
status_callback: 'http://localhost:3000/twilio/delivery_status'
|
||||
) do
|
||||
expect(campaign.reload.completed?).to be false
|
||||
end
|
||||
|
||||
sms_campaign_service.perform
|
||||
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'uses liquid template service to process campaign message' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe UserSessionTrackingService do
|
||||
let(:user) { create(:user) }
|
||||
let(:client_id) { 'client-abc' }
|
||||
let(:request) do
|
||||
instance_double(
|
||||
ActionDispatch::Request,
|
||||
user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15',
|
||||
remote_ip: '8.8.8.8'
|
||||
)
|
||||
end
|
||||
let(:service) { described_class.new(user: user, request: request, client_id: client_id) }
|
||||
let(:geo_result) { OpenStruct.new(city: 'Mountain View', country: 'United States', country_code: 'US') }
|
||||
let(:ip_lookup) { instance_double(IpLookupService, perform: geo_result) }
|
||||
|
||||
before { allow(IpLookupService).to receive(:new).and_return(ip_lookup) }
|
||||
|
||||
describe '#create_or_update!' do
|
||||
it 'creates a new UserSession with the right client_id and timestamps' do
|
||||
expect { service.create_or_update! }.to change(user.user_sessions, :count).by(1)
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.client_id).to eq(client_id)
|
||||
expect(session.last_activity_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
|
||||
it 'populates request, browser, and geo metadata on the new session', :aggregate_failures do
|
||||
service.create_or_update!
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.ip_address).to eq('8.8.8.8')
|
||||
expect(session.browser_name).to eq('Safari')
|
||||
expect(session.platform_name).to eq('macOS')
|
||||
expect(session.city).to eq('Mountain View')
|
||||
expect(session.country).to eq('United States')
|
||||
expect(session.country_code).to eq('US')
|
||||
end
|
||||
|
||||
it 'updates an existing session when client_id matches' do
|
||||
existing = user.user_sessions.create!(client_id: client_id, ip_address: '1.1.1.1', last_activity_at: 1.day.ago)
|
||||
|
||||
expect { service.create_or_update! }.not_to change(user.user_sessions, :count)
|
||||
expect(existing.reload.ip_address).to eq('8.8.8.8')
|
||||
expect(existing.last_activity_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
|
||||
it 'handles missing geo data gracefully' do
|
||||
allow(ip_lookup).to receive(:perform).and_return(nil)
|
||||
|
||||
service.create_or_update!
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.city).to be_nil
|
||||
expect(session.country).to be_nil
|
||||
expect(session.country_code).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_activity!' do
|
||||
it 'does nothing when no session exists for the client_id' do
|
||||
expect { service.update_activity! }.not_to change(user.user_sessions, :count)
|
||||
end
|
||||
|
||||
it 'does nothing when the session was recently active' do
|
||||
session = user.user_sessions.create!(client_id: client_id, last_activity_at: 1.minute.ago)
|
||||
before_ts = session.last_activity_at
|
||||
|
||||
service.update_activity!
|
||||
|
||||
expect(session.reload.last_activity_at).to be_within(1.second).of(before_ts)
|
||||
end
|
||||
|
||||
it 'bumps last_activity_at when the session is stale' do
|
||||
session = user.user_sessions.create!(client_id: client_id, last_activity_at: 10.minutes.ago)
|
||||
|
||||
service.update_activity!
|
||||
|
||||
expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
|
||||
it 'bumps last_activity_at when last_activity_at is nil' do
|
||||
session = user.user_sessions.create!(client_id: client_id, last_activity_at: nil)
|
||||
|
||||
service.update_activity!
|
||||
|
||||
expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -82,19 +82,6 @@ describe Whatsapp::OneoffCampaignService do
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'marks the campaign completed after processing the audience' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
expect(whatsapp_channel).to receive(:send_template) do
|
||||
expect(campaign.reload.completed?).to be false
|
||||
end
|
||||
|
||||
described_class.new(campaign: campaign).perform
|
||||
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'processes contacts with matching labels' do
|
||||
contact_with_label1, contact_with_label2, contact_with_both_labels =
|
||||
create_list(:contact, 3, :with_phone_number, account: account)
|
||||
|
||||
@@ -145,34 +145,6 @@ inbox_create_payload:
|
||||
$ref: ./request/inbox/create_payload.yml
|
||||
inbox_update_payload:
|
||||
$ref: ./request/inbox/update_payload.yml
|
||||
inbox_create_web_widget_channel_payload:
|
||||
$ref: ./request/inbox/channels/create_web_widget_channel_payload.yml
|
||||
inbox_create_api_channel_payload:
|
||||
$ref: ./request/inbox/channels/create_api_channel_payload.yml
|
||||
inbox_create_email_channel_payload:
|
||||
$ref: ./request/inbox/channels/create_email_channel_payload.yml
|
||||
inbox_create_line_channel_payload:
|
||||
$ref: ./request/inbox/channels/create_line_channel_payload.yml
|
||||
inbox_create_telegram_channel_payload:
|
||||
$ref: ./request/inbox/channels/create_telegram_channel_payload.yml
|
||||
inbox_create_whatsapp_channel_payload:
|
||||
$ref: ./request/inbox/channels/create_whatsapp_channel_payload.yml
|
||||
inbox_create_sms_channel_payload:
|
||||
$ref: ./request/inbox/channels/create_sms_channel_payload.yml
|
||||
inbox_update_web_widget_channel_payload:
|
||||
$ref: ./request/inbox/channels/update_web_widget_channel_payload.yml
|
||||
inbox_update_api_channel_payload:
|
||||
$ref: ./request/inbox/channels/update_api_channel_payload.yml
|
||||
inbox_update_email_channel_payload:
|
||||
$ref: ./request/inbox/channels/update_email_channel_payload.yml
|
||||
inbox_update_line_channel_payload:
|
||||
$ref: ./request/inbox/channels/update_line_channel_payload.yml
|
||||
inbox_update_telegram_channel_payload:
|
||||
$ref: ./request/inbox/channels/update_telegram_channel_payload.yml
|
||||
inbox_update_whatsapp_channel_payload:
|
||||
$ref: ./request/inbox/channels/update_whatsapp_channel_payload.yml
|
||||
inbox_update_sms_channel_payload:
|
||||
$ref: ./request/inbox/channels/update_sms_channel_payload.yml
|
||||
|
||||
# Team
|
||||
team_create_update_payload:
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
type: object
|
||||
title: API channel
|
||||
required:
|
||||
- type
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: ['api']
|
||||
example: api
|
||||
webhook_url:
|
||||
type: string
|
||||
description: Webhook URL for API channel inbox callbacks
|
||||
example: 'https://example.com/webhook'
|
||||
hmac_mandatory:
|
||||
type: boolean
|
||||
description: Require HMAC verification for incoming API channel messages
|
||||
example: false
|
||||
additional_attributes:
|
||||
type: object
|
||||
description: Additional attributes stored on contacts created through the API channel
|
||||
example:
|
||||
source: mobile_app
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user