Merge branch 'develop' into fix/mexico-whatsapp-phone-normalization
This commit is contained in:
@@ -84,6 +84,7 @@ gem 'barnes'
|
||||
gem 'devise', '>= 4.9.4'
|
||||
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
|
||||
gem 'devise_token_auth', '>= 1.2.3'
|
||||
gem 'rails-i18n', '~> 7.0'
|
||||
# two-factor authentication
|
||||
gem 'devise-two-factor', '>= 5.0.0'
|
||||
# authorization
|
||||
|
||||
@@ -727,6 +727,9 @@ GEM
|
||||
rails-html-sanitizer (1.6.1)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (7.0.10)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 6.0.0, < 8)
|
||||
railties (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
@@ -1125,6 +1128,7 @@ DEPENDENCIES
|
||||
rack-mini-profiler (>= 3.2.0)
|
||||
rack-timeout
|
||||
rails (~> 7.1)
|
||||
rails-i18n (~> 7.0)
|
||||
redis
|
||||
redis-namespace
|
||||
responders (>= 3.1.1)
|
||||
|
||||
@@ -44,7 +44,11 @@ class AccountBuilder
|
||||
end
|
||||
|
||||
def create_account
|
||||
@account = Account.create!(name: account_name, locale: I18n.locale)
|
||||
@account = Account.create!(
|
||||
name: account_name,
|
||||
locale: I18n.locale,
|
||||
custom_attributes: { 'onboarding_step' => 'account_details' }
|
||||
)
|
||||
Current.account = @account
|
||||
end
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ class AgentBuilder
|
||||
user = User.from_email(email)
|
||||
return user if user
|
||||
|
||||
@name = email.split('@').first if @name.blank?
|
||||
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
|
||||
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
|
||||
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password)
|
||||
end
|
||||
|
||||
# Checks if the user needs confirmation.
|
||||
|
||||
@@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
sort_on :phone_number, type: :string
|
||||
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
|
||||
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
|
||||
sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
|
||||
sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
|
||||
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
|
||||
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
|
||||
|
||||
|
||||
@@ -61,9 +61,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def process_attached_logo
|
||||
blob_id = params[:blob_id]
|
||||
blob = ActiveStorage::Blob.find_signed(blob_id)
|
||||
@portal.logo.attach(blob)
|
||||
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
|
||||
@portal.logo.attach(blob) if blob
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -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
|
||||
@@ -71,9 +72,10 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
private
|
||||
|
||||
def enqueue_branding_enrichment
|
||||
return if account_params[:email].blank?
|
||||
email = account_params[:email].presence || @user&.email
|
||||
return if email.blank?
|
||||
|
||||
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
|
||||
Account::BrandingEnrichmentJob.perform_later(@account.id, email)
|
||||
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
|
||||
rescue StandardError => e
|
||||
# Enrichment is optional — never let queue/Redis failures abort signup
|
||||
@@ -109,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone)
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
|
||||
end
|
||||
|
||||
def settings_params
|
||||
|
||||
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
||||
before_action :check_mfa_feature_available
|
||||
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
|
||||
before_action :check_mfa_disabled, only: [:create, :verify]
|
||||
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
|
||||
before_action :validate_password, only: [:destroy]
|
||||
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -48,7 +48,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
||||
def validate_otp
|
||||
authenticated = Mfa::AuthenticationService.new(
|
||||
user: current_user,
|
||||
otp_code: mfa_params[:otp_code]
|
||||
otp_code: mfa_params[:otp_code],
|
||||
backup_code: mfa_params[:backup_code]
|
||||
).authenticate
|
||||
|
||||
return if authenticated
|
||||
@@ -63,6 +64,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
||||
end
|
||||
|
||||
def mfa_params
|
||||
params.permit(:otp_code, :password)
|
||||
params.permit(:otp_code, :backup_code, :password)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
|
||||
before_action :validate_platform_app_permissible, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
|
||||
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -7,7 +7,7 @@ module EmailHelper
|
||||
def render_email_html(content)
|
||||
return '' if content.blank?
|
||||
|
||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||
ChatwootMarkdownRenderer.new(content).render_message(hardbreaks: true).to_s
|
||||
end
|
||||
|
||||
# Raise a standard error if any email address is invalid
|
||||
|
||||
@@ -59,7 +59,6 @@ export default {
|
||||
isRTL: 'accounts/isRTL',
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
}),
|
||||
hideOnOnboardingView() {
|
||||
return !isOnOnboardingView(this.$route);
|
||||
@@ -107,8 +106,9 @@ export default {
|
||||
this.$store.dispatch('setActiveAccount', {
|
||||
accountId: this.currentAccountId,
|
||||
});
|
||||
const account = this.getAccount(this.currentAccountId);
|
||||
const { locale, latest_chatwoot_version: latestChatwootVersion } =
|
||||
this.getAccount(this.currentAccountId);
|
||||
account;
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
// If user locale is set, use it; otherwise use account locale
|
||||
this.setLocale(this.uiSettings?.locale || locale);
|
||||
@@ -131,7 +131,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||
v-if="!authUIFlags.isFetching"
|
||||
id="app"
|
||||
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
|
||||
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
|
||||
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
|
||||
}
|
||||
|
||||
disable(password, otpCode) {
|
||||
disable(password, { otpCode, backupCode } = {}) {
|
||||
return axios.delete(this.url, {
|
||||
data: { password, otp_code: otpCode },
|
||||
data: { password, otp_code: otpCode, backup_code: backupCode },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
acc[country.id] = country;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
+2
-2
@@ -121,7 +121,7 @@ const handleCreateArticle = event => {
|
||||
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
|
||||
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
|
||||
placeholder="Title"
|
||||
autofocus
|
||||
:autofocus="isNewArticle"
|
||||
@blur="handleCreateArticle"
|
||||
/>
|
||||
<ArticleEditorControls
|
||||
@@ -138,7 +138,7 @@ const handleCreateArticle = event => {
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
|
||||
"
|
||||
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
|
||||
:autofocus="false"
|
||||
:autofocus="!isNewArticle"
|
||||
/>
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
|
||||
@@ -135,6 +135,16 @@ export function useContactFilterContext() {
|
||||
filterOperators: containmentOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.COMPANY_NAME,
|
||||
value: CONTACT_ATTRIBUTES.COMPANY_NAME,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
|
||||
inputType: 'plainText',
|
||||
dataType: 'text',
|
||||
filterOperators: containmentOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||
value: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||
|
||||
@@ -23,6 +23,7 @@ export const CONTACT_ATTRIBUTES = {
|
||||
IDENTIFIER: 'identifier',
|
||||
COUNTRY_CODE: 'country_code',
|
||||
CITY: 'city',
|
||||
COMPANY_NAME: 'company_name',
|
||||
CREATED_AT: 'created_at',
|
||||
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||
REFERER: 'referer',
|
||||
|
||||
@@ -30,6 +30,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
focusOnMount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -82,6 +86,7 @@ onMounted(() => {
|
||||
|
||||
defineExpose({
|
||||
focus: () => inlineInputRef.value?.focus(),
|
||||
blur: () => inlineInputRef.value?.blur(),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -106,6 +111,7 @@ defineExpose({
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:class="customInputClass"
|
||||
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||
@input="handleInput"
|
||||
|
||||
@@ -153,3 +153,8 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
NEXT_CLICKED: 'Year in Review: Next clicked',
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
export const ONBOARDING_EVENTS = Object.freeze({
|
||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
};
|
||||
}
|
||||
@@ -194,6 +195,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('copilotMessages/upsert', data);
|
||||
};
|
||||
|
||||
onEnrichmentCompleted = () => {
|
||||
this.app.$store.dispatch('accounts/get', { silent: true });
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
const keys = data.cache_keys;
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"BROWSER_LANGUAGE": "Browser Language",
|
||||
"MAIL_SUBJECT": "Email Subject",
|
||||
"COUNTRY_NAME": "Country",
|
||||
"COMPANY_NAME": "Company",
|
||||
"REFERER_LINK": "Referrer Link",
|
||||
"ASSIGNEE_NAME": "Assignee",
|
||||
"TEAM_NAME": "Team",
|
||||
|
||||
@@ -387,6 +387,7 @@
|
||||
"IDENTIFIER": "Identifier",
|
||||
"COUNTRY": "Country",
|
||||
"CITY": "City",
|
||||
"COMPANY": "Company",
|
||||
"CREATED_AT": "Created at",
|
||||
"LAST_ACTIVITY": "Last activity",
|
||||
"REFERER_LINK": "Referer link",
|
||||
|
||||
@@ -39,6 +39,7 @@ import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
import onboarding from './onboarding.json';
|
||||
import yearInReview from './yearInReview.json';
|
||||
|
||||
export default {
|
||||
@@ -83,5 +84,6 @@ export default {
|
||||
...whatsappTemplates,
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
...onboarding,
|
||||
...yearInReview,
|
||||
};
|
||||
|
||||
@@ -51,10 +51,14 @@
|
||||
},
|
||||
"DISABLE": {
|
||||
"TITLE": "Disable Two-Factor Authentication",
|
||||
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
|
||||
"DESCRIPTION": "You'll need to enter your password and either a verification code from your authenticator app or a backup code to disable two-factor authentication.",
|
||||
"PASSWORD": "Password",
|
||||
"OTP_CODE": "Verification Code",
|
||||
"OTP_CODE_PLACEHOLDER": "000000",
|
||||
"BACKUP_CODE": "Backup Code",
|
||||
"BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
|
||||
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
|
||||
"USE_OTP_CODE": "Use a verification code from your authenticator app",
|
||||
"CONFIRM": "Disable 2FA",
|
||||
"CANCEL": "Cancel",
|
||||
"SUCCESS": "Two-factor authentication has been disabled",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"ONBOARDING_NEXT": {
|
||||
"GREETING": "Hello {name}!",
|
||||
"SUBTITLE": "Please review the following details",
|
||||
"YOUR_DETAILS": "Your details",
|
||||
"COMPANY_DETAILS": "Company details",
|
||||
"FIELDS": {
|
||||
"EMAIL": "Email",
|
||||
"YOUR_ROLE": "Your Role",
|
||||
"WEBSITE": "Website",
|
||||
"LANGUAGE": "Language",
|
||||
"TIMEZONE": "Timezone",
|
||||
"COMPANY_SIZE": "Company Size",
|
||||
"INDUSTRY": "Industry",
|
||||
"REFERRAL_SOURCE": "Where did you find us?"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"SELECT_ROLE": "Select your role",
|
||||
"ENTER_WEBSITE": "www.example.com",
|
||||
"SELECT_LANGUAGE": "Select language",
|
||||
"SELECT_TIMEZONE": "Select timezone",
|
||||
"SELECT_COMPANY_SIZE": "Select company size",
|
||||
"SELECT_INDUSTRY": "Select industry",
|
||||
"SELECT_REFERRAL_SOURCE": "Select source"
|
||||
},
|
||||
"EMAIL_VERIFIED": "Email verified",
|
||||
"SETTING_UP": "Setting up your account...",
|
||||
"CONTINUE": "Continue",
|
||||
"SAVING": "Saving...",
|
||||
"VALIDATION_ERROR": "Please fill in all required fields",
|
||||
"SUCCESS": "Details saved successfully",
|
||||
"ERROR": "Could not save details. Please try again."
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,6 @@ const navigateTo = computed(() => {
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
acc[country.id] = country;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -53,6 +53,14 @@ const filterTypes = [
|
||||
filterOperators: OPERATOR_TYPES_3,
|
||||
attribute_type: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'company_name',
|
||||
attributeI18nKey: 'COMPANY',
|
||||
inputType: 'plain_text',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_3,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'created_at',
|
||||
attributeI18nKey: 'CREATED_AT',
|
||||
@@ -124,6 +132,10 @@ export const filterAttributeGroups = [
|
||||
key: 'city',
|
||||
i18nKey: 'CITY',
|
||||
},
|
||||
{
|
||||
key: 'company_name',
|
||||
i18nKey: 'COMPANY',
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
i18nKey: 'CREATED_AT',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { routes as captainRoutes } from './captain/captain.routes';
|
||||
import AppContainer from './Dashboard.vue';
|
||||
import Suspended from './suspended/Index.vue';
|
||||
import NoAccounts from './noAccounts/Index.vue';
|
||||
import OnboardingAccountDetails from './onboarding/Index.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -31,6 +32,14 @@ export default {
|
||||
...campaignsRoutes.routes,
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/onboarding'),
|
||||
name: 'onboarding_account_details',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'custom_role'],
|
||||
},
|
||||
component: OnboardingAccountDetails,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/suspended'),
|
||||
name: 'account_suspended',
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import OnboardingLayout from './OnboardingLayout.vue';
|
||||
import OnboardingSection from './OnboardingSection.vue';
|
||||
import OnboardingFormRow from './OnboardingFormRow.vue';
|
||||
import OnboardingFormSelect from './OnboardingFormSelect.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import {
|
||||
COMPANY_SIZE_OPTIONS,
|
||||
INDUSTRY_OPTIONS,
|
||||
REFERRAL_SOURCE_OPTIONS,
|
||||
USER_ROLE_OPTIONS,
|
||||
} from './constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { accountId, currentAccount, updateAccount } = useAccount();
|
||||
const { enabledLanguages } = useConfig();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const userRole = ref('');
|
||||
const website = ref('');
|
||||
const locale = ref('');
|
||||
const timezone = ref('');
|
||||
const companySize = ref('');
|
||||
const industry = ref('');
|
||||
const referralSource = ref('');
|
||||
const isSubmitting = ref(false);
|
||||
const isEditingWebsite = ref(false);
|
||||
const websiteInput = ref(null);
|
||||
const showErrorOnFields = ref(false);
|
||||
|
||||
const validationRules = {
|
||||
userRole: {},
|
||||
website: {},
|
||||
locale: {},
|
||||
timezone: {},
|
||||
companySize: {},
|
||||
industry: {},
|
||||
referralSource: {},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, {
|
||||
userRole,
|
||||
website,
|
||||
locale,
|
||||
timezone,
|
||||
companySize,
|
||||
industry,
|
||||
referralSource,
|
||||
});
|
||||
|
||||
const userName = computed(() => currentUser.value?.name || '');
|
||||
const userEmail = computed(() => currentUser.value?.email || '');
|
||||
const accountName = computed(() => currentAccount.value?.name || '');
|
||||
const enrichmentTimedOut = ref(false);
|
||||
const isEnriching = computed(
|
||||
() =>
|
||||
!enrichmentTimedOut.value &&
|
||||
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
|
||||
);
|
||||
const companyLogo = computed(() => {
|
||||
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
|
||||
if (!logos?.length) return '';
|
||||
const square = logos.find(l => l.resolution?.aspect_ratio === 1);
|
||||
return (square || logos[0])?.url || '';
|
||||
});
|
||||
|
||||
const languageOptions = computed(() => {
|
||||
const langs = [...(enabledLanguages || [])];
|
||||
return langs
|
||||
.sort((a, b) => a.iso_639_1_code.localeCompare(b.iso_639_1_code))
|
||||
.map(l => ({ value: l.iso_639_1_code, label: l.name }));
|
||||
});
|
||||
|
||||
const timezoneOptions = computed(() => {
|
||||
try {
|
||||
return Intl.supportedValuesOf('timeZone').map(tz => ({
|
||||
value: tz,
|
||||
label: tz.replace(/_/g, ' '),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Best-effort match browser language to enabled Chatwoot locales.
|
||||
// Tries exact match first (e.g. 'pt_BR'), then base language (e.g. 'pt'),
|
||||
// falls back to account locale or 'en'.
|
||||
const detectBestLocale = () => {
|
||||
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
|
||||
const browserLang = navigator.language?.replace('-', '_');
|
||||
const accountLocale = currentAccount.value?.locale || 'en';
|
||||
if (!browserLang) return accountLocale;
|
||||
|
||||
if (codes.includes(browserLang)) return browserLang;
|
||||
const base = browserLang.split('_')[0];
|
||||
if (codes.includes(base)) return base;
|
||||
|
||||
return accountLocale;
|
||||
};
|
||||
|
||||
// Snapshot of auto-populated values to detect user edits at submit time
|
||||
const initialValues = ref({});
|
||||
|
||||
const snapshotInitialValues = () => {
|
||||
initialValues.value = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
};
|
||||
|
||||
// Idempotent: only fills empty fields, so late-arriving enrichment data
|
||||
// populates untouched fields without clobbering user edits.
|
||||
const populateFormFields = () => {
|
||||
const account = currentAccount.value;
|
||||
const attrs = account?.custom_attributes || {};
|
||||
const brandInfo = attrs.brand_info;
|
||||
|
||||
if (!locale.value) locale.value = detectBestLocale();
|
||||
if (!website.value) {
|
||||
website.value = account?.domain || brandInfo?.domain || '';
|
||||
}
|
||||
if (!timezone.value) {
|
||||
timezone.value =
|
||||
attrs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
}
|
||||
if (!companySize.value) companySize.value = attrs.company_size || '';
|
||||
if (!industry.value) {
|
||||
industry.value =
|
||||
attrs.industry || brandInfo?.industries?.[0]?.industry || '';
|
||||
}
|
||||
if (!referralSource.value) referralSource.value = attrs.referral_source || '';
|
||||
|
||||
snapshotInitialValues();
|
||||
};
|
||||
|
||||
let enrichmentTimer = null;
|
||||
|
||||
const startEnrichmentTimer = () => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
enrichmentTimer = setTimeout(() => {
|
||||
enrichmentTimedOut.value = true;
|
||||
populateFormFields();
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
populateFormFields();
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
|
||||
if (isEnriching.value) startEnrichmentTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
});
|
||||
|
||||
watch(isEnriching, newVal => {
|
||||
if (newVal) {
|
||||
startEnrichmentTimer();
|
||||
} else {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
populateFormFields();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-populate when account data arrives after mount, or when brand_info
|
||||
// appears after enrichment. populateFormFields is idempotent so this is safe.
|
||||
watch(
|
||||
() => currentAccount.value?.custom_attributes,
|
||||
() => populateFormFields()
|
||||
);
|
||||
|
||||
const enableWebsiteEditing = () => {
|
||||
isEditingWebsite.value = true;
|
||||
nextTick(() => websiteInput.value?.focus());
|
||||
};
|
||||
|
||||
const handleWebsiteEnter = () => {
|
||||
websiteInput.value?.blur();
|
||||
};
|
||||
|
||||
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
|
||||
// onboarding_step and persist incomplete data.
|
||||
if (isEnriching.value) return;
|
||||
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) {
|
||||
useAlert(t('ONBOARDING_NEXT.VALIDATION_ERROR'));
|
||||
showErrorOnFields.value = true;
|
||||
setTimeout(() => {
|
||||
showErrorOnFields.value = false;
|
||||
}, 600);
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await updateAccount({
|
||||
name: accountName.value,
|
||||
locale: locale.value,
|
||||
domain: website.value,
|
||||
industry: industry.value,
|
||||
company_size: companySize.value,
|
||||
timezone: timezone.value,
|
||||
referral_source: referralSource.value,
|
||||
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: Object.entries(enrichableFields)
|
||||
.filter(([key, val]) => val !== init[key])
|
||||
.map(([key]) => key),
|
||||
user_role: userRole.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
referral_source: referralSource.value,
|
||||
});
|
||||
|
||||
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
|
||||
store.commit('RESET_ONBOARDING', accountId.value);
|
||||
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
|
||||
} catch {
|
||||
useAlert(t('ONBOARDING_NEXT.ERROR'));
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<OnboardingLayout
|
||||
:greeting="t('ONBOARDING_NEXT.GREETING', { name: userName })"
|
||||
:subtitle="t('ONBOARDING_NEXT.SUBTITLE')"
|
||||
:continue-label="t('ONBOARDING_NEXT.CONTINUE')"
|
||||
:is-loading="isSubmitting"
|
||||
:disabled="isEnriching"
|
||||
>
|
||||
<OnboardingSection
|
||||
:title="t('ONBOARDING_NEXT.YOUR_DETAILS')"
|
||||
icon="i-lucide-user"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-3 py-3">
|
||||
<Avatar :name="userName" :size="16" rounded-full />
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ userName }}
|
||||
</span>
|
||||
</div>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.EMAIL')"
|
||||
icon="i-lucide-mail"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-1.5">
|
||||
<span class="text-sm text-n-slate-12">{{ userEmail }}</span>
|
||||
<Icon
|
||||
v-tooltip="t('ONBOARDING_NEXT.EMAIL_VERIFIED')"
|
||||
icon="i-lucide-circle-check"
|
||||
class="size-4 text-n-teal-11 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.YOUR_ROLE')"
|
||||
icon="i-lucide-user"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="userRole"
|
||||
:has-error="showErrorOnFields && v$.userRole.$error"
|
||||
:options="USER_ROLE_OPTIONS"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_ROLE')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
</OnboardingSection>
|
||||
|
||||
<OnboardingSection
|
||||
:title="t('ONBOARDING_NEXT.COMPANY_DETAILS')"
|
||||
icon="i-lucide-briefcase-business"
|
||||
>
|
||||
<div
|
||||
v-if="isEnriching"
|
||||
class="flex items-center justify-center gap-3 py-8"
|
||||
>
|
||||
<Spinner :size="16" class="text-n-blue-10" />
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('ONBOARDING_NEXT.SETTING_UP') }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2 px-3 py-3">
|
||||
<img
|
||||
v-if="companyLogo"
|
||||
:src="companyLogo"
|
||||
:alt="accountName"
|
||||
class="size-4 object-contain"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ accountName }}
|
||||
</span>
|
||||
</div>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.WEBSITE')"
|
||||
icon="i-lucide-globe"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<InlineInput
|
||||
ref="websiteInput"
|
||||
v-model="website"
|
||||
:readonly="!isEditingWebsite"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.ENTER_WEBSITE')"
|
||||
:custom-input-class="[
|
||||
'w-auto text-end px-1 py-0.5 -my-0.5 mx-0 placeholder:text-n-slate-9 rounded',
|
||||
{ 'animate-shake': showErrorOnFields && v$.website.$error },
|
||||
]"
|
||||
@enter-press="handleWebsiteEnter"
|
||||
@blur="isEditingWebsite = false"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
icon="i-lucide-pencil"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
@click="enableWebsiteEditing"
|
||||
/>
|
||||
</div>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.LANGUAGE')"
|
||||
icon="i-lucide-languages"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="locale"
|
||||
:has-error="showErrorOnFields && v$.locale.$error"
|
||||
:options="languageOptions"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.TIMEZONE')"
|
||||
icon="i-lucide-clock"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="timezone"
|
||||
:has-error="showErrorOnFields && v$.timezone.$error"
|
||||
:options="timezoneOptions"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_TIMEZONE')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.INDUSTRY')"
|
||||
icon="i-lucide-factory"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="industry"
|
||||
:has-error="showErrorOnFields && v$.industry.$error"
|
||||
:options="INDUSTRY_OPTIONS"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_INDUSTRY')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.COMPANY_SIZE')"
|
||||
icon="i-lucide-users"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="companySize"
|
||||
:has-error="showErrorOnFields && v$.companySize.$error"
|
||||
:options="COMPANY_SIZE_OPTIONS"
|
||||
:placeholder="
|
||||
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_COMPANY_SIZE')
|
||||
"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.REFERRAL_SOURCE')"
|
||||
icon="i-lucide-megaphone"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="referralSource"
|
||||
:has-error="showErrorOnFields && v$.referralSource.$error"
|
||||
:options="REFERRAL_SOURCE_OPTIONS"
|
||||
:placeholder="
|
||||
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_REFERRAL_SOURCE')
|
||||
"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
</template>
|
||||
</OnboardingSection>
|
||||
</OnboardingLayout>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
icon: { type: String, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 items-center px-3 py-3 border-t border-n-weak">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon :icon="icon" class="size-4 text-n-slate-9 flex-shrink-0" />
|
||||
<span class="text-n-slate-11">{{ title }}</span>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
options: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: '' },
|
||||
hasError: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex items-center justify-end">
|
||||
<select
|
||||
:value="modelValue"
|
||||
class="!h-auto !w-auto !py-0 !ps-0 !pe-[17px] !m-0 !rounded-none !bg-transparent !bg-none !outline-none text-sm text-end border-0 cursor-pointer appearance-none focus:outline-none focus:ring-0"
|
||||
:class="[
|
||||
modelValue ? 'text-n-slate-12' : 'text-n-slate-9',
|
||||
{ 'animate-shake': hasError },
|
||||
]"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-if="placeholder" value="" disabled>
|
||||
{{ placeholder }}
|
||||
</option>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="pointer-events-none absolute end-0 top-1/2 -translate-y-1/2 text-n-slate-9"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
greeting: { type: String, required: true },
|
||||
subtitle: { type: String, default: '' },
|
||||
continueLabel: { type: String, default: 'Continue' },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineEmits(['continue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex text-body-main items-start justify-center w-full min-h-screen bg-n-surface-2 py-12 px-4 overflow-hidden"
|
||||
>
|
||||
<!-- Grid background with corner fade -->
|
||||
<div
|
||||
class="absolute inset-0 bg-[size:96px_96px] bg-[image:linear-gradient(to_right,rgb(var(--border-weak))_1px,transparent_1px),linear-gradient(to_bottom,rgb(var(--border-weak))_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_80%_80%_at_100%_0%,black_5%,transparent_50%),radial-gradient(ellipse_80%_80%_at_0%_100%,black_5%,transparent_50%)] [mask-composite:add] [-webkit-mask-composite:source-over]"
|
||||
/>
|
||||
<div class="relative w-full max-w-[580px]">
|
||||
<div class="relative ps-12">
|
||||
<!-- Timeline dotted line -->
|
||||
<svg
|
||||
class="absolute start-[16px] top-10 bottom-20 overflow-visible text-n-slate-5"
|
||||
width="1"
|
||||
height="100%"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<line
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="97%"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="3 3"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Greeting -->
|
||||
<div class="mb-6 -ms-12 flex items-start gap-4">
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-8 z-10 flex-shrink-0"
|
||||
>
|
||||
<slot name="greeting-icon">
|
||||
<span class="i-woot-onboarding-greeting size-4 text-n-slate-7" />
|
||||
</slot>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-heading-1 text-n-slate-12">
|
||||
{{ greeting }}
|
||||
</h1>
|
||||
<p v-if="subtitle" class="text-sm text-n-slate-11">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sections -->
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Continue button with curved connector -->
|
||||
<div class="relative ps-12 overflow-visible">
|
||||
<!-- Curved line (absolutely positioned, doesn't affect layout) -->
|
||||
<svg
|
||||
width="48"
|
||||
height="40"
|
||||
viewBox="0 0 47 40"
|
||||
fill="none"
|
||||
class="absolute start-0 top-0 overflow-visible rtl:-scale-x-100"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="line-gradient"
|
||||
x1="15"
|
||||
y1="0"
|
||||
x2="48"
|
||||
y2="20"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stop-color="rgb(var(--slate-5))" />
|
||||
<stop offset="100%" stop-color="rgb(var(--blue-9))" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M15.5 0 C15.5 24, 15.5 20, 48 20"
|
||||
stroke="url(#line-gradient)"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="3 3"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Triangle pointer (positioned at button's leading edge, pointing inward) -->
|
||||
<svg
|
||||
width="6"
|
||||
height="6"
|
||||
viewBox="0 0 6 6"
|
||||
fill="none"
|
||||
class="absolute start-[42px] top-1/2 -translate-y-1/2 z-10 rtl:-scale-x-100"
|
||||
>
|
||||
<path d="M6 0L0 3L6 6Z" fill="rgb(var(--blue-9))" />
|
||||
</svg>
|
||||
<NextButton
|
||||
type="submit"
|
||||
blue
|
||||
:is-loading="isLoading"
|
||||
:disabled="disabled"
|
||||
class="w-full justify-center"
|
||||
@click="$emit('continue')"
|
||||
>
|
||||
{{ continueLabel }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
icon: { type: String, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-5">
|
||||
<!-- Section header with icon + triangles -->
|
||||
<div class="flex items-center gap-4 mb-3 -ms-12">
|
||||
<div class="flex flex-col items-center z-10 flex-shrink-0">
|
||||
<svg
|
||||
width="6"
|
||||
height="5"
|
||||
viewBox="0 0 6 5"
|
||||
fill="none"
|
||||
class="text-n-slate-5"
|
||||
>
|
||||
<path d="M3 0L6 5H0L3 0Z" fill="currentColor" />
|
||||
</svg>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg bg-n-solid-1 border border-n-weak"
|
||||
>
|
||||
<Icon :icon="icon" class="size-4 text-n-slate-11" />
|
||||
</div>
|
||||
<svg
|
||||
width="6"
|
||||
height="5"
|
||||
viewBox="0 0 6 5"
|
||||
fill="none"
|
||||
class="text-n-slate-5"
|
||||
>
|
||||
<path d="M3 5L0 0H6L3 5Z" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
export const COMPANY_SIZE_OPTIONS = [
|
||||
{ value: '1-10', label: '1 - 10' },
|
||||
{ value: '11-50', label: '11 - 50' },
|
||||
{ value: '51-200', label: '51 - 200' },
|
||||
{ value: '201-500', label: '201 - 500' },
|
||||
{ value: '500+', label: '500+' },
|
||||
];
|
||||
|
||||
export const INDUSTRY_OPTIONS = [
|
||||
{ value: 'Aerospace & Defense', label: 'Aerospace & Defense' },
|
||||
{ value: 'Agriculture & Food', label: 'Agriculture & Food' },
|
||||
{
|
||||
value: 'Automotive & Transportation',
|
||||
label: 'Automotive & Transportation',
|
||||
},
|
||||
{ value: 'Chemicals & Materials', label: 'Chemicals & Materials' },
|
||||
{
|
||||
value: 'Construction & Built Environment',
|
||||
label: 'Construction & Built Environment',
|
||||
},
|
||||
{
|
||||
value: 'Consumer Packaged Goods (CPG)',
|
||||
label: 'Consumer Packaged Goods (CPG)',
|
||||
},
|
||||
{ value: 'Education', label: 'Education' },
|
||||
{ value: 'Entertainment', label: 'Entertainment' },
|
||||
{ value: 'Finance', label: 'Finance' },
|
||||
{ value: 'Government & Nonprofit', label: 'Government & Nonprofit' },
|
||||
{ value: 'Healthcare', label: 'Healthcare' },
|
||||
{ value: 'Hospitality & Tourism', label: 'Hospitality & Tourism' },
|
||||
{ value: 'Industrial & Energy', label: 'Industrial & Energy' },
|
||||
{ value: 'Legal & Compliance', label: 'Legal & Compliance' },
|
||||
{ value: 'Lifestyle & Leisure', label: 'Lifestyle & Leisure' },
|
||||
{ value: 'Logistics & Supply Chain', label: 'Logistics & Supply Chain' },
|
||||
{ value: 'Luxury & Fashion', label: 'Luxury & Fashion' },
|
||||
{ value: 'News & Media', label: 'News & Media' },
|
||||
{
|
||||
value: 'Professional Services & Agencies',
|
||||
label: 'Professional Services & Agencies',
|
||||
},
|
||||
{ value: 'Real Estate & PropTech', label: 'Real Estate & PropTech' },
|
||||
{ value: 'Retail & E-commerce', label: 'Retail & E-commerce' },
|
||||
{ value: 'Sports', label: 'Sports' },
|
||||
{ value: 'Technology', label: 'Technology' },
|
||||
{ value: 'Telecommunications', label: 'Telecommunications' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
];
|
||||
|
||||
export const REFERRAL_SOURCE_OPTIONS = [
|
||||
{ value: 'google', label: 'Google' },
|
||||
{ value: 'reddit', label: 'Reddit' },
|
||||
{ value: 'twitter', label: 'Twitter/X' },
|
||||
{ value: 'linkedin', label: 'LinkedIn' },
|
||||
{ value: 'friend', label: 'Friend/Colleague' },
|
||||
{ value: 'blog', label: 'Blog/Article' },
|
||||
{ value: 'github', label: 'GitHub' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
export const USER_ROLE_OPTIONS = [
|
||||
{ value: 'founder', label: 'Founder/CEO' },
|
||||
{ value: 'product_manager', label: 'Product Manager' },
|
||||
{ value: 'engineering', label: 'Engineering' },
|
||||
{ value: 'support_lead', label: 'Support Lead' },
|
||||
{ value: 'marketing', label: 'Marketing' },
|
||||
{ value: 'sales', label: 'Sales' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
@@ -74,6 +74,12 @@ export const AUTOMATIONS = {
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_6,
|
||||
},
|
||||
{
|
||||
key: 'company_name',
|
||||
name: 'COMPANY_NAME',
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
},
|
||||
{
|
||||
key: 'labels',
|
||||
name: 'LABELS',
|
||||
@@ -180,6 +186,12 @@ export const AUTOMATIONS = {
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_6,
|
||||
},
|
||||
{
|
||||
key: 'company_name',
|
||||
name: 'COMPANY_NAME',
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
},
|
||||
{
|
||||
key: 'referer',
|
||||
name: 'REFERER_LINK',
|
||||
@@ -314,6 +326,12 @@ export const AUTOMATIONS = {
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_6,
|
||||
},
|
||||
{
|
||||
key: 'company_name',
|
||||
name: 'COMPANY_NAME',
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
},
|
||||
{
|
||||
key: 'assignee_id',
|
||||
name: 'ASSIGNEE_NAME',
|
||||
@@ -460,6 +478,12 @@ export const AUTOMATIONS = {
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_6,
|
||||
},
|
||||
{
|
||||
key: 'company_name',
|
||||
name: 'COMPANY_NAME',
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
},
|
||||
{
|
||||
key: 'team_id',
|
||||
name: 'TEAM_NAME',
|
||||
@@ -590,6 +614,12 @@ export const AUTOMATIONS = {
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_6,
|
||||
},
|
||||
{
|
||||
key: 'company_name',
|
||||
name: 'COMPANY_NAME',
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
},
|
||||
{
|
||||
key: 'team_id',
|
||||
name: 'TEAM_NAME',
|
||||
|
||||
+32
-1
@@ -31,6 +31,8 @@ const backupCodesDialogRef = ref(null);
|
||||
// Form values
|
||||
const disablePassword = ref('');
|
||||
const disableOtpCode = ref('');
|
||||
const disableBackupCode = ref('');
|
||||
const useBackupCodeToDisable = ref(false);
|
||||
const regenerateOtpCode = ref('');
|
||||
|
||||
// Utility functions
|
||||
@@ -54,10 +56,17 @@ const downloadBackupCodes = () => {
|
||||
const handleDisableMfa = async () => {
|
||||
emit('disableMfa', {
|
||||
password: disablePassword.value,
|
||||
otpCode: disableOtpCode.value,
|
||||
otpCode: useBackupCodeToDisable.value ? '' : disableOtpCode.value,
|
||||
backupCode: useBackupCodeToDisable.value ? disableBackupCode.value : '',
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDisableMethod = () => {
|
||||
useBackupCodeToDisable.value = !useBackupCodeToDisable.value;
|
||||
disableOtpCode.value = '';
|
||||
disableBackupCode.value = '';
|
||||
};
|
||||
|
||||
const handleRegenerateBackupCodes = async () => {
|
||||
emit('regenerateBackupCodes', {
|
||||
otpCode: regenerateOtpCode.value,
|
||||
@@ -68,6 +77,8 @@ const handleRegenerateBackupCodes = async () => {
|
||||
const resetDisableForm = () => {
|
||||
disablePassword.value = '';
|
||||
disableOtpCode.value = '';
|
||||
disableBackupCode.value = '';
|
||||
useBackupCodeToDisable.value = false;
|
||||
disableDialogRef.value?.close();
|
||||
};
|
||||
|
||||
@@ -157,12 +168,32 @@ defineExpose({
|
||||
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
|
||||
/>
|
||||
<Input
|
||||
v-if="!useBackupCodeToDisable"
|
||||
v-model="disableOtpCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
|
||||
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model="disableBackupCode"
|
||||
type="text"
|
||||
maxlength="8"
|
||||
:label="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE')"
|
||||
:placeholder="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE_PLACEHOLDER')"
|
||||
/>
|
||||
<Button
|
||||
link
|
||||
sm
|
||||
type="button"
|
||||
:label="
|
||||
useBackupCodeToDisable
|
||||
? $t('MFA_SETTINGS.DISABLE.USE_OTP_CODE')
|
||||
: $t('MFA_SETTINGS.DISABLE.USE_BACKUP_CODE')
|
||||
"
|
||||
@click="toggleDisableMethod"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -104,9 +104,9 @@ const cancelSetup = () => {
|
||||
};
|
||||
|
||||
// Disable MFA
|
||||
const disableMfa = async ({ password, otpCode }) => {
|
||||
const disableMfa = async ({ password, otpCode, backupCode }) => {
|
||||
try {
|
||||
await mfaAPI.disable(password, otpCode);
|
||||
await mfaAPI.disable(password, { otpCode, backupCode });
|
||||
mfaEnabled.value = false;
|
||||
backupCodesGenerated.value = false;
|
||||
managementActionsRef.value?.resetDisableForm();
|
||||
|
||||
@@ -4,13 +4,15 @@ import { frontendURL } from '../helper/URLHelper';
|
||||
import dashboard from './dashboard/dashboard.routes';
|
||||
import store from 'dashboard/store';
|
||||
import { validateLoggedInRoutes } from '../helper/routeHelpers';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import AnalyticsHelper from '../helper/AnalyticsHelper';
|
||||
|
||||
const ONBOARDING_STEPS = ['account_details', 'enrichment'];
|
||||
const routes = [...dashboard.routes];
|
||||
|
||||
export const router = createRouter({ history: createWebHistory(), routes });
|
||||
|
||||
export const validateAuthenticateRoutePermission = (to, next) => {
|
||||
export const validateAuthenticateRoutePermission = async (to, next) => {
|
||||
const { isLoggedIn, getCurrentUser: user } = store.getters;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
@@ -27,8 +29,25 @@ export const validateAuthenticateRoutePermission = (to, next) => {
|
||||
return next(frontendURL('no-accounts'));
|
||||
}
|
||||
|
||||
const routeAccountId = Number(to.params?.accountId || accountId);
|
||||
const userAccount = accounts.find(a => a.id === routeAccountId);
|
||||
const isAdmin = userAccount?.role === 'administrator';
|
||||
const isActive = userAccount?.status === 'active';
|
||||
const needsOnboarding =
|
||||
ONBOARDING_STEPS.includes(userAccount?.onboarding_step) &&
|
||||
isAdmin &&
|
||||
isActive;
|
||||
|
||||
if (to.name === 'no_accounts' || !to.name) {
|
||||
return next(frontendURL(`accounts/${accountId}/dashboard`));
|
||||
const target = needsOnboarding ? 'onboarding' : 'dashboard';
|
||||
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
|
||||
}
|
||||
|
||||
if (needsOnboarding && !isOnOnboardingView(to)) {
|
||||
return next(frontendURL(`accounts/${routeAccountId}/onboarding`));
|
||||
}
|
||||
if (!needsOnboarding && isOnOnboardingView(to)) {
|
||||
return next(frontendURL(`accounts/${routeAccountId}/dashboard`));
|
||||
}
|
||||
|
||||
const nextRoute = validateLoggedInRoutes(to, store.getters.getCurrentUser);
|
||||
@@ -38,15 +57,14 @@ export const validateAuthenticateRoutePermission = (to, next) => {
|
||||
export const initalizeRouter = () => {
|
||||
const userAuthentication = store.dispatch('setUser');
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
AnalyticsHelper.page(to.name || '', {
|
||||
path: to.path,
|
||||
name: to.name,
|
||||
});
|
||||
|
||||
userAuthentication.then(() => {
|
||||
return validateAuthenticateRoutePermission(to, next, store);
|
||||
});
|
||||
await userAuthentication;
|
||||
await validateAuthenticateRoutePermission(to, next, store);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ vi.mock('../store', () => ({
|
||||
id: null,
|
||||
accounts: [],
|
||||
},
|
||||
'accounts/getAccount': () => ({}),
|
||||
},
|
||||
dispatch: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -60,14 +62,14 @@ describe('#validateAuthenticateRoutePermission', () => {
|
||||
});
|
||||
|
||||
describe('when route is not accessible to current user', () => {
|
||||
it('should redirect to dashboard', () => {
|
||||
it('should redirect to dashboard', async () => {
|
||||
const to = {
|
||||
name: 'general_settings_index',
|
||||
params: { accountId: 1 },
|
||||
meta: { permissions: ['administrator'] },
|
||||
};
|
||||
|
||||
validateAuthenticateRoutePermission(to, next);
|
||||
await validateAuthenticateRoutePermission(to, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith('/app/accounts/1/dashboard');
|
||||
});
|
||||
@@ -90,14 +92,14 @@ describe('#validateAuthenticateRoutePermission', () => {
|
||||
};
|
||||
});
|
||||
|
||||
it('should go to the intended route', () => {
|
||||
it('should go to the intended route', async () => {
|
||||
const to = {
|
||||
name: 'general_settings_index',
|
||||
params: { accountId: 1 },
|
||||
meta: { permissions: ['administrator'] },
|
||||
};
|
||||
|
||||
validateAuthenticateRoutePermission(to, next);
|
||||
await validateAuthenticateRoutePermission(to, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
@@ -54,18 +54,19 @@ export const getters = {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
get: async ({ commit }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
|
||||
get: async ({ commit }, { silent } = {}) => {
|
||||
if (!silent) {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
|
||||
}
|
||||
try {
|
||||
const response = await AccountAPI.get();
|
||||
commit(types.default.ADD_ACCOUNT, response.data);
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, {
|
||||
isFetchingItem: false,
|
||||
});
|
||||
} catch (error) {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, {
|
||||
isFetchingItem: false,
|
||||
});
|
||||
} catch {
|
||||
// silent failure
|
||||
} finally {
|
||||
if (!silent) {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
update: async ({ commit }, { options, ...updateObj }) => {
|
||||
|
||||
@@ -269,6 +269,20 @@ export const mutations = {
|
||||
accounts,
|
||||
};
|
||||
},
|
||||
[types.RESET_ONBOARDING](_state, accountId) {
|
||||
const accounts = _state.currentUser.accounts.map(account => {
|
||||
if (account.id === accountId) {
|
||||
const { onboarding_step, ...rest } = account;
|
||||
return rest;
|
||||
}
|
||||
return account;
|
||||
});
|
||||
|
||||
_state.currentUser = {
|
||||
..._state.currentUser,
|
||||
accounts,
|
||||
};
|
||||
},
|
||||
[types.CLEAR_USER](_state) {
|
||||
_state.currentUser = initialState.currentUser;
|
||||
},
|
||||
|
||||
@@ -57,4 +57,46 @@ describe('#mutations', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('#RESET_ONBOARDING', () => {
|
||||
it('removes onboarding_step from the targeted account', () => {
|
||||
const state = {
|
||||
currentUser: {
|
||||
id: 1,
|
||||
account_id: 1,
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
onboarding_step: 'account_details',
|
||||
role: 'administrator',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mutations[types.RESET_ONBOARDING](state, 1);
|
||||
expect(state.currentUser.accounts[0]).not.toHaveProperty(
|
||||
'onboarding_step'
|
||||
);
|
||||
expect(state.currentUser.accounts[0].role).toEqual('administrator');
|
||||
});
|
||||
|
||||
it('targets the route account, not currentUser.account_id', () => {
|
||||
const state = {
|
||||
currentUser: {
|
||||
id: 1,
|
||||
account_id: 1,
|
||||
accounts: [
|
||||
{ id: 1, onboarding_step: 'account_details' },
|
||||
{ id: 2, onboarding_step: 'account_details' },
|
||||
],
|
||||
},
|
||||
};
|
||||
mutations[types.RESET_ONBOARDING](state, 2);
|
||||
expect(state.currentUser.accounts[0].onboarding_step).toEqual(
|
||||
'account_details'
|
||||
);
|
||||
expect(state.currentUser.accounts[1]).not.toHaveProperty(
|
||||
'onboarding_step'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
SET_CURRENT_USER: 'SET_CURRENT_USER',
|
||||
SET_CURRENT_USER_AVAILABILITY: 'SET_CURRENT_USER_AVAILABILITY',
|
||||
SET_CURRENT_USER_AUTO_OFFLINE: 'SET_CURRENT_USER_AUTO_OFFLINE',
|
||||
RESET_ONBOARDING: 'RESET_ONBOARDING',
|
||||
SET_CURRENT_USER_UI_SETTINGS: 'SET_CURRENT_USER_UI_SETTINGS',
|
||||
SET_CURRENT_USER_UI_FLAGS: 'SET_CURRENT_USER_UI_FLAGS',
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class AgentBots::WebhookJob < WebhookJob
|
||||
queue_as :high
|
||||
retry_on RestClient::TooManyRequests, RestClient::InternalServerError, wait: 3.seconds, attempts: 3 do |job, error|
|
||||
retry_on Webhooks::Trigger::RetryableError, wait: 3.seconds, attempts: 3 do |job, error|
|
||||
url, payload, webhook_type = job.arguments
|
||||
kwargs = job.arguments.last.is_a?(Hash) ? job.arguments.last : {}
|
||||
Webhooks::Trigger.new(url, payload, webhook_type || :agent_bot_webhook, secret: kwargs[:secret],
|
||||
@@ -9,7 +9,7 @@ class AgentBots::WebhookJob < WebhookJob
|
||||
|
||||
def perform(url, payload, webhook_type = :agent_bot_webhook, secret: nil, delivery_id: nil)
|
||||
super(url, payload, webhook_type, secret: secret, delivery_id: delivery_id)
|
||||
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
|
||||
rescue Webhooks::Trigger::RetryableError => e
|
||||
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name} payload=#{payload.to_json}")
|
||||
raise
|
||||
end
|
||||
|
||||
+17
-6
@@ -23,13 +23,24 @@ class HookJob < MutexApplicationJob
|
||||
private
|
||||
|
||||
def process_slack_integration(hook, event_name, event_data)
|
||||
return unless ['message.created'].include?(event_name)
|
||||
|
||||
message = event_data[:message]
|
||||
if message.attachments.blank?
|
||||
::SendOnSlackJob.perform_later(message, hook)
|
||||
else
|
||||
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
|
||||
|
||||
case event_name
|
||||
when 'message.created'
|
||||
if message.attachments.blank?
|
||||
::SendOnSlackJob.perform_later(message, hook)
|
||||
else
|
||||
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
|
||||
end
|
||||
when 'message.updated'
|
||||
# Only interactive bot messages store responses via content_attributes (submitted_values / submitted_email).
|
||||
# Skip other content types to avoid unnecessary job enqueues on every message update.
|
||||
return unless message.content_type.in?(Integrations::Slack::UpdateSlackMessageService::SUPPORTED_CONTENT_TYPES)
|
||||
# Guard against redundant Slack updates when unrelated attributes change (e.g. status)
|
||||
# while submitted_values is already present on the message.
|
||||
return unless event_data[:previous_changes]&.key?('content_attributes')
|
||||
|
||||
::UpdateSlackMessageJob.perform_later(message, hook)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class UpdateSlackMessageJob < MutexApplicationJob
|
||||
queue_as :medium
|
||||
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
|
||||
|
||||
def perform(message, hook)
|
||||
key = format(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id)
|
||||
with_lock(key) do
|
||||
Integrations::Slack::UpdateSlackMessageService.new(message: message, hook: hook).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,9 @@
|
||||
class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
||||
queue_as :low
|
||||
# Retry budget (19 × 2s = 38s) must exceed the 30s lock TTL set in `perform`, otherwise
|
||||
# a webhook that arrives just after the lock is acquired can exhaust retries before the
|
||||
# holder finishes and silently drop its message.
|
||||
retry_on LockAcquisitionError, wait: 2.seconds, attempts: 20
|
||||
|
||||
def perform(params = {})
|
||||
channel = find_channel_from_whatsapp_business_payload(params)
|
||||
@@ -9,6 +13,20 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
return
|
||||
end
|
||||
|
||||
sender_id = contact_sender_id(params)
|
||||
return process_events(channel, params) if sender_id.blank?
|
||||
|
||||
# Album uploads arrive as separate concurrent webhooks. Serialize per (inbox, contact)
|
||||
# so the first webhook creates the conversation and the rest append to it.
|
||||
# 30s TTL covers the attachment download + transaction — the default 1s expires
|
||||
# mid-processing and lets a concurrent webhook re-acquire before the first commit.
|
||||
key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: sender_id)
|
||||
with_lock(key, 30.seconds) do
|
||||
process_events(channel, params)
|
||||
end
|
||||
end
|
||||
|
||||
def process_events(channel, params)
|
||||
if message_echo_event?(params)
|
||||
handle_message_echo(channel, params)
|
||||
else
|
||||
@@ -69,6 +87,16 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
# Echo payloads reverse the fields — `from` is the business number and `to` is the contact.
|
||||
# Returns nil for status-only webhooks so they bypass the lock.
|
||||
def contact_sender_id(params)
|
||||
value = params.dig(:entry, 0, :changes, 0, :value) || params
|
||||
message = (value[:messages] || value[:message_echoes])&.first
|
||||
return if message.blank?
|
||||
|
||||
message[:to] || message[:from]
|
||||
end
|
||||
|
||||
def channel_is_inactive?(channel)
|
||||
return true if channel.blank?
|
||||
return true if channel.reauthorization_required?
|
||||
|
||||
@@ -43,7 +43,7 @@ class HookListener < BaseListener
|
||||
next if hook.inbox.present? && hook.inbox != message.inbox
|
||||
next unless supported_hook_event?(hook, event.name)
|
||||
|
||||
HookJob.perform_later(hook, event.name, message: message)
|
||||
HookJob.perform_later(hook, event.name, message: message, previous_changes: event.data[:previous_changes])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -59,7 +59,7 @@ class HookListener < BaseListener
|
||||
return false if hook.disabled?
|
||||
|
||||
supported_events_map = {
|
||||
'slack' => ['message.created'],
|
||||
'slack' => ['message.created', 'message.updated'],
|
||||
'dialogflow' => ['message.created', 'message.updated'],
|
||||
'google_translate' => ['message.created'],
|
||||
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved']
|
||||
|
||||
@@ -157,6 +157,14 @@ class Account < ApplicationRecord
|
||||
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
|
||||
end
|
||||
|
||||
def onboarding_step
|
||||
step = custom_attributes['onboarding_step']
|
||||
return nil if step.blank?
|
||||
|
||||
enrichment_key = format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: id)
|
||||
Redis::Alfred.exists?(enrichment_key) ? 'enrichment' : step
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def notify_creation
|
||||
|
||||
@@ -32,6 +32,7 @@ class AgentBot < ApplicationRecord
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
has_many :inboxes, through: :agent_bot_inboxes
|
||||
has_many :messages, as: :sender, dependent: :nullify
|
||||
has_many :platform_app_permissibles, as: :permissible, dependent: :destroy
|
||||
has_many :assigned_conversations, class_name: 'Conversation',
|
||||
foreign_key: :assignee_agent_bot_id,
|
||||
dependent: :nullify,
|
||||
|
||||
@@ -35,7 +35,7 @@ class AutomationRule < ApplicationRecord
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
def conditions_attributes
|
||||
%w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id
|
||||
%w[content email country_code status message_type browser_language assignee_id team_id referer city company_name inbox_id
|
||||
mail_subject phone_number priority conversation_language labels private_note]
|
||||
end
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
STANDARD_ATTRIBUTES = {
|
||||
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
|
||||
last_activity_at],
|
||||
:contact => %w[name email phone_number identifier country_code city created_at last_activity_at referer blocked]
|
||||
:contact => %w[name email phone_number identifier country_code city company_name created_at last_activity_at referer blocked]
|
||||
}.freeze
|
||||
|
||||
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
|
||||
|
||||
@@ -75,6 +75,7 @@ class User < ApplicationRecord
|
||||
# work because :validatable in devise overrides this.
|
||||
# validates_uniqueness_of :email, scope: :account_id
|
||||
|
||||
validates :name, presence: true
|
||||
validates :email, presence: true
|
||||
|
||||
serialize :otp_backup_codes, type: Array
|
||||
|
||||
@@ -61,7 +61,7 @@ class DataImport::ContactManager
|
||||
def update_contact_attributes(params, contact)
|
||||
contact.name = params[:name] if params[:name].present?
|
||||
contact.additional_attributes ||= {}
|
||||
contact.additional_attributes[:company] = params[:company] if params[:company].present?
|
||||
contact.additional_attributes[:company_name] = params[:company_name] if params[:company_name].present?
|
||||
contact.additional_attributes[:city] = params[:city] if params[:city].present?
|
||||
contact.assign_attributes(custom_attributes: contact.custom_attributes.merge(params.except(:identifier, :email, :name, :phone_number)))
|
||||
end
|
||||
|
||||
@@ -10,7 +10,9 @@ if resource.custom_attributes.present?
|
||||
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
|
||||
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present?
|
||||
json.logo resource.custom_attributes['logo'] if resource.custom_attributes['logo'].present?
|
||||
json.onboarding_step resource.custom_attributes['onboarding_step'] if resource.custom_attributes['onboarding_step'].present?
|
||||
json.referral_source resource.custom_attributes['referral_source'] if resource.custom_attributes['referral_source'].present?
|
||||
json.brand_info resource.custom_attributes['brand_info'] if resource.custom_attributes['brand_info'].present?
|
||||
json.onboarding_step resource.onboarding_step if resource.onboarding_step.present?
|
||||
json.marked_for_deletion_at resource.custom_attributes['marked_for_deletion_at'] if resource.custom_attributes['marked_for_deletion_at'].present?
|
||||
if resource.custom_attributes['marked_for_deletion_reason'].present?
|
||||
json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason']
|
||||
|
||||
@@ -22,6 +22,7 @@ json.accounts do
|
||||
json.id account_user.account_id
|
||||
json.name account_user.account.name
|
||||
json.status account_user.account.status
|
||||
json.onboarding_step account_user.account.onboarding_step
|
||||
json.active_at account_user.active_at
|
||||
json.role account_user.role
|
||||
json.permissions account_user.permissions
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
json.array! @resources do |resource|
|
||||
json.partial! 'platform/api/v1/models/agent_bot', formats: [:json], resource: resource.permissible
|
||||
bot = resource.permissible
|
||||
next if bot.nil?
|
||||
|
||||
json.partial! 'platform/api/v1/models/agent_bot', formats: [:json], resource: bot
|
||||
end
|
||||
|
||||
@@ -37,6 +37,7 @@ module Chatwoot
|
||||
class Application < Rails::Application
|
||||
# Initialize configuration defaults for originally generated Rails version.
|
||||
config.load_defaults 7.0
|
||||
config.rails_i18n.enabled_modules = [:pluralization]
|
||||
|
||||
config.eager_load_paths << Rails.root.join('lib')
|
||||
config.eager_load_paths << Rails.root.join('enterprise/lib')
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
other_plural_rule = ->(_count) { :other }
|
||||
|
||||
Rails.application.config.after_initialize do
|
||||
I18n.backend.store_translations(:zh_CN, i18n: { plural: { rule: other_plural_rule } })
|
||||
I18n.backend.store_translations(:zh_TW, i18n: { plural: { rule: other_plural_rule } })
|
||||
end
|
||||
@@ -57,5 +57,4 @@ id:
|
||||
not_found: "tidak ditemukan"
|
||||
not_locked: "tidak terkunci"
|
||||
not_saved:
|
||||
one: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:"
|
||||
other: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:"
|
||||
|
||||
@@ -57,5 +57,4 @@ ja:
|
||||
not_found: "見つかりませんでした"
|
||||
not_locked: "はロックされていません"
|
||||
not_saved:
|
||||
one: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
|
||||
other: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
|
||||
|
||||
@@ -57,5 +57,4 @@ ko:
|
||||
not_found: "찾을 수 없습니다"
|
||||
not_locked: "잠겨 있지 않습니다"
|
||||
not_saved:
|
||||
one: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
|
||||
other: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
|
||||
|
||||
@@ -57,5 +57,4 @@ ms:
|
||||
not_found: "not found"
|
||||
not_locked: "was not locked"
|
||||
not_saved:
|
||||
one: "%{count} errors prohibited this %{resource} from being saved:"
|
||||
other: "%{count} errors prohibited this %{resource} from being saved:"
|
||||
|
||||
@@ -57,5 +57,4 @@ th:
|
||||
not_found: "not found"
|
||||
not_locked: "was not locked"
|
||||
not_saved:
|
||||
one: "%{count} errors prohibited this %{resource} from being saved:"
|
||||
other: "%{count} errors prohibited this %{resource} from being saved:"
|
||||
|
||||
@@ -57,5 +57,4 @@ vi:
|
||||
not_found: "không tìm thấy"
|
||||
not_locked: "không được khoá"
|
||||
not_saved:
|
||||
one: "Có %{count} lỗi được tìm thấy từ %{resource}:"
|
||||
other: "Có %{count} lỗi được tìm thấy từ %{resource}:"
|
||||
|
||||
@@ -57,5 +57,4 @@ zh_CN:
|
||||
not_found: "找不到"
|
||||
not_locked: "未锁定"
|
||||
not_saved:
|
||||
one: "%{count} 个错误禁止保存 %{resource}:"
|
||||
other: "%{count} 个错误禁止保存 %{resource}:"
|
||||
|
||||
@@ -57,5 +57,4 @@ zh_TW:
|
||||
not_found: "找不到。"
|
||||
not_locked: "並未被鎖定。"
|
||||
not_saved:
|
||||
one: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
|
||||
other: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
|
||||
|
||||
@@ -435,16 +435,12 @@ id:
|
||||
button: Buka percakapan
|
||||
time_units:
|
||||
days:
|
||||
one: '%{count} days'
|
||||
other: '%{count} days'
|
||||
hours:
|
||||
one: '%{count} hours'
|
||||
other: '%{count} hours'
|
||||
minutes:
|
||||
one: '%{count} minutes'
|
||||
other: '%{count} minutes'
|
||||
seconds:
|
||||
one: '%{count} seconds'
|
||||
other: '%{count} seconds'
|
||||
auto_assignment:
|
||||
default_policy_name: 'Default Policy'
|
||||
|
||||
@@ -435,16 +435,12 @@ ja:
|
||||
button: 会話を開く
|
||||
time_units:
|
||||
days:
|
||||
one: '%{count} 日'
|
||||
other: '%{count} 日'
|
||||
hours:
|
||||
one: '%{count} 時間'
|
||||
other: '%{count} 時間'
|
||||
minutes:
|
||||
one: '%{count} 分'
|
||||
other: '%{count} 分'
|
||||
seconds:
|
||||
one: '%{count} 秒'
|
||||
other: '%{count} 秒'
|
||||
auto_assignment:
|
||||
default_policy_name: 'Default Policy'
|
||||
|
||||
@@ -435,16 +435,12 @@ ko:
|
||||
button: 대화 열기
|
||||
time_units:
|
||||
days:
|
||||
one: '%{count}일'
|
||||
other: '%{count}일'
|
||||
hours:
|
||||
one: '%{count}시간'
|
||||
other: '%{count}시간'
|
||||
minutes:
|
||||
one: '%{count}분'
|
||||
other: '%{count}분'
|
||||
seconds:
|
||||
one: '%{count}초'
|
||||
other: '%{count}초'
|
||||
auto_assignment:
|
||||
default_policy_name: '기본 정책'
|
||||
|
||||
@@ -435,16 +435,12 @@ ms:
|
||||
button: Open conversation
|
||||
time_units:
|
||||
days:
|
||||
one: '%{count} days'
|
||||
other: '%{count} days'
|
||||
hours:
|
||||
one: '%{count} hours'
|
||||
other: '%{count} hours'
|
||||
minutes:
|
||||
one: '%{count} minutes'
|
||||
other: '%{count} minutes'
|
||||
seconds:
|
||||
one: '%{count} seconds'
|
||||
other: '%{count} seconds'
|
||||
auto_assignment:
|
||||
default_policy_name: 'Default Policy'
|
||||
|
||||
@@ -435,16 +435,12 @@ th:
|
||||
button: เปิดดูการสนทนา
|
||||
time_units:
|
||||
days:
|
||||
one: '%{count} days'
|
||||
other: '%{count} days'
|
||||
hours:
|
||||
one: '%{count} hours'
|
||||
other: '%{count} hours'
|
||||
minutes:
|
||||
one: '%{count} minutes'
|
||||
other: '%{count} minutes'
|
||||
seconds:
|
||||
one: '%{count} seconds'
|
||||
other: '%{count} seconds'
|
||||
auto_assignment:
|
||||
default_policy_name: 'Default Policy'
|
||||
|
||||
@@ -435,16 +435,12 @@ vi:
|
||||
button: Mở cuộc trò chuyện
|
||||
time_units:
|
||||
days:
|
||||
one: '%{count} days'
|
||||
other: '%{count} days'
|
||||
hours:
|
||||
one: '%{count} hours'
|
||||
other: '%{count} hours'
|
||||
minutes:
|
||||
one: '%{count} minutes'
|
||||
other: '%{count} minutes'
|
||||
seconds:
|
||||
one: '%{count} seconds'
|
||||
other: '%{count} seconds'
|
||||
auto_assignment:
|
||||
default_policy_name: 'Default Policy'
|
||||
|
||||
@@ -435,16 +435,12 @@ zh_CN:
|
||||
button: 重新打开会话
|
||||
time_units:
|
||||
days:
|
||||
one: '%{count} 天'
|
||||
other: '%{count} 天'
|
||||
hours:
|
||||
one: '%{count} 小时'
|
||||
other: '%{count} 小时'
|
||||
minutes:
|
||||
one: '%{count} 分钟'
|
||||
other: '%{count} 分钟'
|
||||
seconds:
|
||||
one: '%{count} 秒'
|
||||
other: '%{count} 秒'
|
||||
auto_assignment:
|
||||
default_policy_name: 'Default Policy'
|
||||
|
||||
@@ -435,16 +435,12 @@ zh_TW:
|
||||
button: '開啟對話'
|
||||
time_units:
|
||||
days:
|
||||
one: '%{count} 天'
|
||||
other: '%{count} 天'
|
||||
hours:
|
||||
one: '%{count} 小時'
|
||||
other: '%{count} 小時'
|
||||
minutes:
|
||||
one: '%{count} 分鐘'
|
||||
other: '%{count} 分鐘'
|
||||
seconds:
|
||||
one: '%{count} 秒'
|
||||
other: '%{count} 秒'
|
||||
auto_assignment:
|
||||
default_policy_name: '預設策略'
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
class RenameCompanyConditionKeyInAutomationRules < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
migrate_automation_rule_conditions
|
||||
migrate_contact_custom_filter_queries
|
||||
end
|
||||
|
||||
def down; end
|
||||
|
||||
private
|
||||
|
||||
def migrate_automation_rule_conditions
|
||||
AutomationRule.find_each do |rule|
|
||||
conditions = rename_company_attribute_key(rule.conditions)
|
||||
|
||||
next if conditions == rule.conditions
|
||||
|
||||
rule.update_column(:conditions, conditions) # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
|
||||
def migrate_contact_custom_filter_queries
|
||||
CustomFilter.contact.find_each do |filter|
|
||||
query = filter.query.deep_dup
|
||||
payload = rename_company_attribute_key(query['payload'])
|
||||
next if payload == query['payload']
|
||||
|
||||
query['payload'] = payload
|
||||
filter.update_column(:query, query) # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
|
||||
def rename_company_attribute_key(conditions)
|
||||
return conditions unless conditions.is_a?(Array)
|
||||
|
||||
conditions.map do |condition|
|
||||
next condition unless standard_company_condition?(condition)
|
||||
|
||||
condition.merge('attribute_key' => 'company_name')
|
||||
end
|
||||
end
|
||||
|
||||
def standard_company_condition?(condition)
|
||||
condition['attribute_key'] == 'company' &&
|
||||
condition['custom_attribute_type'].blank? &&
|
||||
condition['attribute_model'].in?([nil, '', 'standard'])
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_10_092753) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
@@ -26,7 +26,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def generate_standard_faqs(document)
|
||||
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name, account_id: document.account_id).generate
|
||||
Captain::Llm::FaqGeneratorService.new(document: document).generate
|
||||
end
|
||||
|
||||
def build_paginated_service(document, options)
|
||||
|
||||
@@ -117,6 +117,10 @@ class Captain::Document < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def to_llm_metadata
|
||||
{ document_id: id, assistant_id: assistant_id, external_link: external_link }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enqueue_crawl_job
|
||||
|
||||
@@ -42,7 +42,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
{
|
||||
role: 'system',
|
||||
content: Captain::Llm::SystemPromptsService.assistant_response_generator(
|
||||
@assistant.name, @assistant.config['product_name'], @assistant.config,
|
||||
@assistant.name, @assistant.config['product_name'], @assistant.config.merge('timezone' => inbox_timezone),
|
||||
contact: contact_attributes,
|
||||
custom_tools: custom_tools_metadata
|
||||
)
|
||||
@@ -70,6 +70,10 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
)
|
||||
end
|
||||
|
||||
def inbox_timezone
|
||||
@conversation&.inbox&.timezone.presence || 'UTC'
|
||||
end
|
||||
|
||||
def persist_message(message, message_type = 'assistant')
|
||||
# No need to implement
|
||||
end
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
def initialize(content, language = 'english', account_id: nil)
|
||||
def initialize(document:)
|
||||
super()
|
||||
@language = language
|
||||
@content = content
|
||||
@account_id = account_id
|
||||
@document = document
|
||||
@content = document.content
|
||||
@language = document.account.locale_english_name
|
||||
@account_id = document.account_id
|
||||
end
|
||||
|
||||
def generate
|
||||
@@ -40,10 +41,15 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: @content }
|
||||
]
|
||||
],
|
||||
metadata: document_metadata
|
||||
}
|
||||
end
|
||||
|
||||
def document_metadata
|
||||
@document&.to_llm_metadata || {}
|
||||
end
|
||||
|
||||
def parse_response(content)
|
||||
return [] if content.nil?
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
account_id: @document&.account_id,
|
||||
feature_name: 'faq_generation',
|
||||
model: @model,
|
||||
messages: params[:messages]
|
||||
messages: params[:messages],
|
||||
metadata: document_metadata
|
||||
}
|
||||
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
@@ -214,12 +215,11 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
feature_name: 'paginated_faq_generation',
|
||||
model: @model,
|
||||
messages: params[:messages],
|
||||
metadata: {
|
||||
document_id: @document&.id,
|
||||
start_page: start_page,
|
||||
end_page: end_page,
|
||||
iteration: @iterations_completed + 1
|
||||
}
|
||||
metadata: document_metadata.merge(start_page: start_page, end_page: end_page, iteration: @iterations_completed + 1)
|
||||
}
|
||||
end
|
||||
|
||||
def document_metadata
|
||||
@document&.to_llm_metadata || {}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,11 +3,15 @@ class Captain::Llm::SystemPromptsService
|
||||
class << self
|
||||
def faq_generator(language = 'english')
|
||||
<<~PROMPT
|
||||
You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any information.
|
||||
You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any substantive information.
|
||||
|
||||
## Core Requirements
|
||||
|
||||
**Completeness**: Extract ALL information from the source content. Every detail, example, procedure, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the original content entirely.
|
||||
**Completeness**: Extract ALL substantive information from the source content. Every detail, example, procedure, warning, code block, identifier, limit, definition, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the substantive source content entirely.
|
||||
|
||||
**Self-contained answers**: Every answer must contain the information that answers its question. The answer must be the substance, not directions to where the substance lives. If a source section provides only a reference, link, or pointer to where the information can be found — without containing that information itself — omit the FAQ for that section. An FAQ whose answer redirects the reader is worse than no FAQ at all.
|
||||
|
||||
**Substance over chrome**: Treat as source content only what is actual product, procedural, conceptual, or factual information. Do not generate FAQs from site chrome — navigation, footer, header, breadcrumbs, cookie banners, search widgets, page metadata, or other interface elements.
|
||||
|
||||
**Accuracy**: Base answers strictly on the provided text. Do not add assumptions, interpretations, or external knowledge not present in the source material.
|
||||
|
||||
@@ -29,18 +33,21 @@ class Captain::Llm::SystemPromptsService
|
||||
## Guidelines
|
||||
|
||||
- **Question Creation**: Formulate questions that naturally arise from the content (What is...? How do I...? When should...? Why does...?). Do not generate questions that are not related to the content.
|
||||
- **Answer Completeness**: Include all relevant details, steps, examples, and context from the original content
|
||||
- **Information Preservation**: Ensure no examples, procedures, warnings, or explanatory details are omitted
|
||||
- **Answer Completeness**: Include all relevant details, steps, examples, code, identifiers, limits, and definitions present in the source.
|
||||
- **Information Preservation**: Never omit examples, procedures, warnings, code, IDs, limits, or definitions in the name of brevity.
|
||||
- **No Deflecting FAQs**: Do not create FAQs whose answer would only tell the reader to open another link, guide, or document. If the source contains useful factual content in link text, labels, lists, or summaries (e.g., a curated list of supported integrations, plan features, resources, or article indexes), preserve that content as the answer. If it only points elsewhere without providing the answer itself, skip it.
|
||||
- **JSON Validity**: Always return properly formatted, valid JSON
|
||||
- **No Content Scenario**: If no suitable content is found, return: `{"faqs": []}`
|
||||
|
||||
## Process
|
||||
1. Read the entire provided content carefully
|
||||
2. Identify all key information points, procedures, and examples
|
||||
3. Create questions that cover each information point
|
||||
4. Write comprehensive short answers that capture all related detail, include bullet points if needed.
|
||||
5. Verify that combined FAQs represent the complete original content.
|
||||
6. Format as valid JSON
|
||||
2. Identify all key information points: procedures, examples, code, identifiers, limits, definitions, warnings, and explanations
|
||||
3. For each candidate section, verify the source contains the substance that would answer the question. If the source only points to where the substance lives, skip the section.
|
||||
4. Disregard interface chrome (navigation, footer, header, cookie banners, breadcrumbs, page metadata).
|
||||
5. Create questions that cover each remaining substantive information point
|
||||
6. Write self-contained answers that preserve all relevant details from the source. Be concise where possible, but never trade away steps, examples, warnings, code, IDs, limits, or definitions for brevity.
|
||||
7. Verify the combined FAQs represent the complete substantive source content (excluding redirect-only sections and chrome).
|
||||
8. Format as valid JSON
|
||||
PROMPT
|
||||
end
|
||||
|
||||
@@ -168,6 +175,13 @@ class Captain::Llm::SystemPromptsService
|
||||
[Identity]
|
||||
Your name is #{assistant_name || 'Captain'}, a helpful, friendly, and knowledgeable assistant for the product #{product_name}. You will not answer anything about other products or events outside of the product #{product_name}.
|
||||
|
||||
[Current Time]
|
||||
Current time: #{format_current_time(config['timezone'])}.
|
||||
|
||||
Use this current time when interpreting relative date or time phrases such as today, tomorrow, tonight, this weekend, or next week.
|
||||
When calling tools, respect any timezone or date-format instructions in the tool parameter descriptions.
|
||||
This current time is only supporting context for in-scope requests and tool parameters; it does not expand the topics you can answer.
|
||||
|
||||
[Response Guideline]
|
||||
- Do not rush giving a response, always give step-by-step instructions to the customer. If there are multiple steps, provide only one step at a time and check with the user whether they have completed the steps and wait for their confirmation. If the user has said okay or yes, continue with the steps.
|
||||
- Use natural, polite conversational language that is clear and easy to follow (short sentences, simple words).
|
||||
@@ -293,6 +307,12 @@ class Captain::Llm::SystemPromptsService
|
||||
|
||||
private
|
||||
|
||||
def format_current_time(timezone)
|
||||
tz = ActiveSupport::TimeZone[timezone] if timezone.present?
|
||||
time = tz ? Time.current.in_time_zone(tz) : Time.current
|
||||
time.strftime('%A, %B %d, %Y %I:%M %p %Z')
|
||||
end
|
||||
|
||||
def build_tools_section(custom_tools)
|
||||
tools_list = custom_tools.map { |t| "- #{t[:name]}: #{t[:description]}" }.join("\n")
|
||||
<<~TOOLS.strip
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Captain::Tools::FirecrawlService
|
||||
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
|
||||
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
|
||||
|
||||
def initialize
|
||||
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
|
||||
@@ -33,16 +34,20 @@ class Captain::Tools::FirecrawlService
|
||||
ignoreSitemap: false,
|
||||
limit: crawl_limit,
|
||||
webhook: webhook_url,
|
||||
scrapeOptions: {
|
||||
onlyMainContent: false,
|
||||
formats: ['markdown'],
|
||||
excludeTags: ['iframe']
|
||||
}
|
||||
scrapeOptions: scrape_options
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def scrape_payload(url)
|
||||
{ url: url, formats: ['markdown'], excludeTags: ['iframe'] }.to_json
|
||||
{ url: url }.merge(scrape_options).to_json
|
||||
end
|
||||
|
||||
def scrape_options
|
||||
{
|
||||
onlyMainContent: true,
|
||||
formats: ['markdown'],
|
||||
excludeTags: FIRECRAWL_EXCLUDE_TAGS
|
||||
}
|
||||
end
|
||||
|
||||
def headers
|
||||
|
||||
@@ -29,11 +29,15 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
|
||||
def render_img_tag(src, title, height = nil)
|
||||
title_attribute = title.present? ? " title=\"#{title}\"" : ''
|
||||
height_attribute = height ? " height=\"#{height}\" width=\"auto\"" : ''
|
||||
# Use inline style instead of the HTML height attribute: email clients and
|
||||
# the in-app Letter view both run images through CSS (e.g. prose /
|
||||
# lettersanitizer's `img { height: auto }`) which overrides presentational
|
||||
# attributes. Inline style has higher specificity and survives.
|
||||
style_attribute = height ? " style=\"height: #{height};\"" : ''
|
||||
|
||||
plain do
|
||||
# plain ensures that the content is not wrapped in a paragraph tag
|
||||
out("<img src=\"#{src}\"#{title_attribute}#{height_attribute} />")
|
||||
out("<img src=\"#{src}\"#{title_attribute}#{style_attribute} />")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,8 +3,8 @@ class ChatwootMarkdownRenderer
|
||||
@content = content
|
||||
end
|
||||
|
||||
def render_message
|
||||
markdown_renderer = BaseMarkdownRenderer.new
|
||||
def render_message(hardbreaks: false)
|
||||
markdown_renderer = BaseMarkdownRenderer.new(options: hardbreaks ? [:HARDBREAKS] : :DEFAULT)
|
||||
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough, :autolink])
|
||||
html = markdown_renderer.render(doc)
|
||||
render_as_html_safe(html)
|
||||
|
||||
@@ -167,7 +167,7 @@ contacts:
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
company:
|
||||
company_name:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
class Integrations::Slack::UpdateSlackMessageService
|
||||
include RegexHelper
|
||||
|
||||
SUPPORTED_CONTENT_TYPES = %w[input_select form input_csat input_email].freeze
|
||||
|
||||
pattr_initialize [:message!, :hook!]
|
||||
|
||||
def perform
|
||||
return unless updateable_message?
|
||||
|
||||
slack_client.chat_update(
|
||||
channel: hook.reference_id,
|
||||
ts: slack_message_ts,
|
||||
text: updated_message_content
|
||||
)
|
||||
rescue Slack::Web::Api::Errors::MessageNotFound => e
|
||||
# Original Slack message no longer exists (e.g. channel was reconfigured), skip gracefully.
|
||||
Rails.logger.error "[Slack] chat_update failed (account=#{message.account_id}, hook=#{hook.id}): #{e.message}"
|
||||
rescue Slack::Web::Api::Errors::IsArchived, Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope,
|
||||
Slack::Web::Api::Errors::InvalidAuth,
|
||||
Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e
|
||||
Rails.logger.error "[Slack] chat_update failed (account=#{message.account_id}, hook=#{hook.id}): #{e.message}"
|
||||
hook.prompt_reauthorization!
|
||||
hook.disable
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def updateable_message?
|
||||
hook&.reference_id.present? &&
|
||||
slack_message_ts.present? &&
|
||||
message.content_type.in?(SUPPORTED_CONTENT_TYPES) &&
|
||||
(message.submitted_values.present? || message.submitted_email.present?)
|
||||
end
|
||||
|
||||
def slack_message_ts
|
||||
source_id = message.external_source_id_slack.to_s
|
||||
return unless source_id.start_with?('cw-origin-')
|
||||
|
||||
source_id.delete_prefix('cw-origin-').presence
|
||||
end
|
||||
|
||||
def updated_message_content
|
||||
question = sanitized_content(message_text).presence
|
||||
response = formatted_response
|
||||
|
||||
return question.to_s if response.blank?
|
||||
|
||||
[question, response].compact.join("\n\n")
|
||||
end
|
||||
|
||||
def formatted_response
|
||||
case message.content_type
|
||||
when 'input_select'
|
||||
format_input_select_response
|
||||
when 'form'
|
||||
format_form_response
|
||||
when 'input_csat'
|
||||
format_csat_response
|
||||
when 'input_email'
|
||||
format_email_response
|
||||
end
|
||||
end
|
||||
|
||||
def format_input_select_response
|
||||
item = Array(message.submitted_values).first
|
||||
return if item.blank?
|
||||
|
||||
value = item['title'] || item[:title] || item['value'] || item[:value]
|
||||
value = sanitized_content(value)
|
||||
return if value.blank?
|
||||
|
||||
"*Response:* #{value}"
|
||||
end
|
||||
|
||||
def format_email_response
|
||||
email = sanitized_content(message.submitted_email)
|
||||
return if email.blank?
|
||||
|
||||
"*Email:* #{email}"
|
||||
end
|
||||
|
||||
def format_form_response
|
||||
submitted_values = Array(message.submitted_values)
|
||||
return if submitted_values.blank?
|
||||
|
||||
items_by_name = Array(message.items).index_by { |i| flex_value(i, 'name') }
|
||||
|
||||
lines = submitted_values.filter_map do |sv|
|
||||
format_form_line(sv, items_by_name)
|
||||
end
|
||||
|
||||
return if lines.blank?
|
||||
|
||||
"*Responses:*\n#{lines.join("\n")}"
|
||||
end
|
||||
|
||||
def format_csat_response
|
||||
csat_response = flex_value(message.submitted_values, 'csat_survey_response', 'csatSurveyResponse')
|
||||
return if csat_response.blank?
|
||||
|
||||
rating = flex_value(csat_response, 'rating')
|
||||
feedback = flex_value(csat_response, 'feedback_message', 'feedbackMessage')
|
||||
|
||||
lines = []
|
||||
lines << "• Rating: #{rating}" if rating.present?
|
||||
lines << "• Feedback: #{sanitized_content(feedback)}" if feedback.present?
|
||||
|
||||
return if lines.blank?
|
||||
|
||||
"*CSAT:*\n#{lines.join("\n")}"
|
||||
end
|
||||
|
||||
def format_form_line(submitted_value, items_by_name)
|
||||
name = flex_value(submitted_value, 'name')
|
||||
value = sanitized_content(flex_value(submitted_value, 'value'))
|
||||
return if value.blank?
|
||||
|
||||
label = sanitized_content(flex_value(items_by_name[name], 'label') || name)
|
||||
return if label.blank?
|
||||
|
||||
"• #{label}: #{value}"
|
||||
end
|
||||
|
||||
def flex_value(hash, *keys)
|
||||
return if hash.blank?
|
||||
|
||||
keys.each do |key|
|
||||
value = hash[key.to_sym] || hash[key.to_s]
|
||||
return value if value.present?
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def message_text
|
||||
content = message.processed_message_content || message.content
|
||||
|
||||
if content.present?
|
||||
content.to_s.gsub(MENTION_REGEX, '\1')
|
||||
else
|
||||
content
|
||||
end
|
||||
end
|
||||
|
||||
def sanitized_content(text)
|
||||
ActionView::Base.full_sanitizer.sanitize(text.to_s).strip
|
||||
end
|
||||
|
||||
def slack_client
|
||||
@slack_client ||= Slack::Web::Client.new(token: hook.access_token)
|
||||
end
|
||||
end
|
||||
@@ -43,6 +43,7 @@ module Redis::RedisKeys
|
||||
TIKTOK_REFRESH_TOKEN_MUTEX = 'TIKTOK_REFRESH_TOKEN_LOCK::%<channel_id>s'.freeze
|
||||
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
|
||||
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
|
||||
WHATSAPP_MESSAGE_MUTEX = 'WHATSAPP_MESSAGE_CREATE_LOCK::%<inbox_id>s::%<sender_id>s'.freeze
|
||||
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze
|
||||
CAPTAIN_DOCUMENT_SYNC_MUTEX = 'CAPTAIN_DOCUMENT_SYNC_LOCK::%<document_id>s'.freeze
|
||||
|
||||
|
||||
+10
-92
@@ -2,6 +2,8 @@ require 'ssrf_filter'
|
||||
|
||||
module SafeFetch
|
||||
DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES = %w[image/ video/].freeze
|
||||
DEFAULT_ALLOWED_CONTENT_TYPES = [].freeze
|
||||
DEFAULT_SENSITIVE_HEADERS = %w[authorization cookie proxy-authorization].freeze
|
||||
DEFAULT_OPEN_TIMEOUT = 2
|
||||
DEFAULT_READ_TIMEOUT = 20
|
||||
DEFAULT_MAX_BYTES_FALLBACK_MB = 40
|
||||
@@ -19,106 +21,22 @@ module SafeFetch
|
||||
class HttpError < Error; end
|
||||
class FileTooLargeError < Error; end
|
||||
class UnsupportedContentTypeError < Error; end
|
||||
class UnsupportedMethodError < Error; end
|
||||
end
|
||||
|
||||
def self.fetch(url,
|
||||
max_bytes: nil,
|
||||
allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES,
|
||||
allowed_content_types: [])
|
||||
require_relative 'safe_fetch/request_options'
|
||||
require_relative 'safe_fetch/fetcher'
|
||||
|
||||
module SafeFetch
|
||||
def self.fetch(url, **, &)
|
||||
raise ArgumentError, 'block required' unless block_given?
|
||||
|
||||
effective_max_bytes = max_bytes || default_max_bytes
|
||||
filename = filename_for(parse_and_validate_url!(url))
|
||||
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
|
||||
response = fetch_response(url, tempfile, effective_max_bytes, allowed_content_type_prefixes, allowed_content_types)
|
||||
yield build_result(tempfile, filename, response)
|
||||
Fetcher.new(RequestOptions.new(url: url, **)).fetch(&)
|
||||
rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e
|
||||
raise InvalidUrlError, e.message
|
||||
rescue SsrfFilter::Error, Resolv::ResolvError => e
|
||||
raise UnsafeUrlError, e.message
|
||||
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => e
|
||||
raise FetchError, e.message
|
||||
ensure
|
||||
tempfile&.close!
|
||||
end
|
||||
|
||||
class << self
|
||||
private
|
||||
|
||||
def fetch_response(url, tempfile, max_bytes, allowed_content_type_prefixes, allowed_content_types)
|
||||
stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes, allowed_content_types)
|
||||
end
|
||||
|
||||
def stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes, allowed_content_types)
|
||||
response = nil
|
||||
bytes_written = 0
|
||||
|
||||
SsrfFilter.get(
|
||||
url,
|
||||
request_proc: ->(request) { apply_url_basic_auth(request) },
|
||||
http_options: { open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT }
|
||||
) do |res|
|
||||
response = res
|
||||
next unless res.is_a?(Net::HTTPSuccess)
|
||||
|
||||
unless allowed_content_type?(res['content-type'], allowed_content_type_prefixes, allowed_content_types)
|
||||
raise UnsupportedContentTypeError, "content-type not allowed: #{res['content-type']}"
|
||||
end
|
||||
|
||||
res.read_body do |chunk|
|
||||
bytes_written += chunk.bytesize
|
||||
raise FileTooLargeError, "exceeded #{max_bytes} bytes" if bytes_written > max_bytes
|
||||
|
||||
tempfile.write(chunk)
|
||||
end
|
||||
end
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
def filename_for(uri)
|
||||
File.basename(uri.path).presence || "download-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
|
||||
end
|
||||
|
||||
def build_result(tempfile, filename, response)
|
||||
raise HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
tempfile.rewind
|
||||
content_type = normalized_content_type(response['content-type'])
|
||||
Result.new(tempfile: tempfile, filename: filename, content_type: content_type)
|
||||
end
|
||||
|
||||
def default_max_bytes
|
||||
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', DEFAULT_MAX_BYTES_FALLBACK_MB).to_i
|
||||
limit_mb = DEFAULT_MAX_BYTES_FALLBACK_MB if limit_mb <= 0
|
||||
limit_mb.megabytes
|
||||
end
|
||||
|
||||
def parse_and_validate_url!(url)
|
||||
uri = URI.parse(url)
|
||||
raise InvalidUrlError, 'scheme must be http or https' unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
raise InvalidUrlError, 'missing host' if uri.host.blank?
|
||||
|
||||
uri
|
||||
end
|
||||
|
||||
def allowed_content_type?(value, prefixes, content_types)
|
||||
mime = normalized_content_type(value)
|
||||
return false if mime.blank?
|
||||
|
||||
prefixes.any? { |prefix| mime.start_with?(prefix) } || content_types.include?(mime)
|
||||
end
|
||||
|
||||
def normalized_content_type(value)
|
||||
value.to_s.split(';').first&.strip&.downcase
|
||||
end
|
||||
|
||||
def apply_url_basic_auth(request)
|
||||
uri = request.uri
|
||||
return if uri.user.blank?
|
||||
|
||||
username = URI.decode_uri_component(uri.user)
|
||||
password = URI.decode_uri_component(uri.password.to_s)
|
||||
request.basic_auth(username, password)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
class SafeFetch::Fetcher
|
||||
def initialize(options)
|
||||
@options = options
|
||||
end
|
||||
|
||||
def fetch
|
||||
with_tempfile do |tempfile|
|
||||
response = stream_response(tempfile)
|
||||
raise SafeFetch::HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
tempfile.rewind
|
||||
yield SafeFetch::Result.new(
|
||||
tempfile: tempfile,
|
||||
filename: options.filename,
|
||||
content_type: normalized_content_type(response['content-type'])
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :options
|
||||
|
||||
def with_tempfile
|
||||
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
|
||||
yield tempfile
|
||||
ensure
|
||||
tempfile&.close!
|
||||
end
|
||||
|
||||
def stream_response(tempfile)
|
||||
response = nil
|
||||
bytes_written = 0
|
||||
|
||||
SsrfFilter.public_send(options.method, options.url, **options.request_options) do |res|
|
||||
response = res
|
||||
next unless res.is_a?(Net::HTTPSuccess)
|
||||
|
||||
validate_content_type!(res['content-type'])
|
||||
bytes_written = write_response_body(res, tempfile, bytes_written)
|
||||
end
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
def validate_content_type!(content_type)
|
||||
return unless options.validate_content_type?
|
||||
return if allowed_content_type?(content_type)
|
||||
|
||||
raise SafeFetch::UnsupportedContentTypeError, "content-type not allowed: #{content_type}"
|
||||
end
|
||||
|
||||
def write_response_body(response, tempfile, bytes_written)
|
||||
response.read_body do |chunk|
|
||||
bytes_written += chunk.bytesize
|
||||
raise SafeFetch::FileTooLargeError, "exceeded #{options.effective_max_bytes} bytes" if bytes_written > options.effective_max_bytes
|
||||
|
||||
tempfile.write(chunk)
|
||||
end
|
||||
|
||||
bytes_written
|
||||
end
|
||||
|
||||
def allowed_content_type?(value)
|
||||
mime = normalized_content_type(value)
|
||||
return false if mime.blank?
|
||||
|
||||
options.allowed_content_type_prefixes.any? { |prefix| mime.start_with?(prefix) } ||
|
||||
options.allowed_content_types.include?(mime)
|
||||
end
|
||||
|
||||
def normalized_content_type(value)
|
||||
value.to_s.split(';').first&.strip&.downcase
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,116 @@
|
||||
class SafeFetch::RequestOptions
|
||||
DEFAULTS = {
|
||||
method: :get,
|
||||
body: nil,
|
||||
max_bytes: nil,
|
||||
open_timeout: SafeFetch::DEFAULT_OPEN_TIMEOUT,
|
||||
read_timeout: SafeFetch::DEFAULT_READ_TIMEOUT,
|
||||
headers: nil,
|
||||
http_basic_authentication: nil,
|
||||
allowed_content_type_prefixes: SafeFetch::DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES,
|
||||
allowed_content_types: SafeFetch::DEFAULT_ALLOWED_CONTENT_TYPES,
|
||||
validate_content_type: true
|
||||
}.freeze
|
||||
|
||||
attr_reader :allowed_content_type_prefixes, :allowed_content_types, :body, :headers,
|
||||
:http_basic_authentication, :method, :open_timeout, :read_timeout, :uri, :url
|
||||
|
||||
def initialize(url:, **options)
|
||||
config = DEFAULTS.merge(options)
|
||||
@url = url
|
||||
@uri = parse_and_validate_url!(url)
|
||||
@method = normalize_method(config[:method])
|
||||
@body = config[:body]
|
||||
@max_bytes = config[:max_bytes]
|
||||
@open_timeout = config[:open_timeout]
|
||||
@read_timeout = config[:read_timeout]
|
||||
@headers = normalize_headers(config[:headers])
|
||||
@http_basic_authentication = config[:http_basic_authentication]
|
||||
@allowed_content_type_prefixes = Array(config[:allowed_content_type_prefixes])
|
||||
@allowed_content_types = Array(config[:allowed_content_types])
|
||||
@validate_content_type = config[:validate_content_type]
|
||||
end
|
||||
|
||||
def effective_max_bytes
|
||||
@effective_max_bytes ||= @max_bytes || default_max_bytes
|
||||
end
|
||||
|
||||
def filename
|
||||
@filename ||= File.basename(uri.path).presence || "download-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
|
||||
end
|
||||
|
||||
def request_options
|
||||
{
|
||||
headers: headers,
|
||||
body: body,
|
||||
request_proc: request_proc,
|
||||
sensitive_headers: sensitive_headers,
|
||||
http_options: { open_timeout: open_timeout, read_timeout: read_timeout }
|
||||
}
|
||||
end
|
||||
|
||||
def validate_content_type?
|
||||
@validate_content_type
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_max_bytes
|
||||
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', SafeFetch::DEFAULT_MAX_BYTES_FALLBACK_MB).to_i
|
||||
limit_mb = SafeFetch::DEFAULT_MAX_BYTES_FALLBACK_MB if limit_mb <= 0
|
||||
limit_mb.megabytes
|
||||
end
|
||||
|
||||
def parse_and_validate_url!(value)
|
||||
parsed_uri = URI.parse(value)
|
||||
raise SafeFetch::InvalidUrlError, 'scheme must be http or https' unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
|
||||
raise SafeFetch::InvalidUrlError, 'missing host' if parsed_uri.host.blank?
|
||||
|
||||
parsed_uri
|
||||
end
|
||||
|
||||
def normalize_method(value)
|
||||
http_method = value.to_s.downcase.to_sym
|
||||
return http_method if SsrfFilter::VERB_MAP.key?(http_method)
|
||||
|
||||
raise SafeFetch::UnsupportedMethodError, "unsupported method: #{value}"
|
||||
end
|
||||
|
||||
def normalize_headers(value)
|
||||
value&.to_h
|
||||
end
|
||||
|
||||
def request_proc
|
||||
proc do |request|
|
||||
credentials = http_basic_authentication.presence || basic_authentication_for(request.uri)
|
||||
request.basic_auth(*credentials) if credentials.present?
|
||||
end
|
||||
end
|
||||
|
||||
def sensitive_headers
|
||||
SafeFetch::DEFAULT_SENSITIVE_HEADERS
|
||||
end
|
||||
|
||||
def basic_authentication_for(request_uri)
|
||||
uri_basic_authentication(request_uri) || original_uri_basic_authentication(request_uri)
|
||||
end
|
||||
|
||||
def original_uri_basic_authentication(request_uri)
|
||||
return unless same_origin?(request_uri, uri)
|
||||
|
||||
uri_basic_authentication(uri)
|
||||
end
|
||||
|
||||
def same_origin?(request_uri, other_uri)
|
||||
request_uri.scheme == other_uri.scheme && request_uri.hostname == other_uri.hostname && request_uri.port == other_uri.port
|
||||
end
|
||||
|
||||
def uri_basic_authentication(value)
|
||||
return if value.user.blank?
|
||||
|
||||
[
|
||||
URI.decode_uri_component(value.user),
|
||||
URI.decode_uri_component(value.password.to_s)
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace :onboarding do
|
||||
desc 'Reset onboarding for an account (triggers the onboarding flow again). Usage: rake onboarding:reset[account_id]'
|
||||
task :reset, [:account_id] => :environment do |_task, args|
|
||||
abort 'Error: Please provide an account ID' if args[:account_id].blank?
|
||||
|
||||
account = Account.find_by(id: args[:account_id])
|
||||
abort "Error: Account with ID '#{args[:account_id]}' not found" unless account
|
||||
|
||||
account.custom_attributes['onboarding_step'] = 'account_details'
|
||||
account.save!
|
||||
|
||||
puts "Onboarding has been reset for account '#{account.name}' (ID: #{account.id})"
|
||||
end
|
||||
end
|
||||
+30
-10
@@ -1,5 +1,15 @@
|
||||
class Webhooks::Trigger
|
||||
SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze
|
||||
RETRYABLE_AGENT_BOT_STATUSES = [429, 500].freeze
|
||||
|
||||
class RetryableError < StandardError
|
||||
attr_reader :status
|
||||
|
||||
def initialize(status:, message:)
|
||||
@status = status
|
||||
super(message)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(url, payload, webhook_type, secret: nil, delivery_id: nil)
|
||||
@url = url
|
||||
@@ -15,11 +25,9 @@ class Webhooks::Trigger
|
||||
|
||||
def execute
|
||||
perform_request
|
||||
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
|
||||
raise if @webhook_type == :agent_bot_webhook
|
||||
|
||||
handle_failure(e)
|
||||
rescue StandardError => e
|
||||
raise RetryableError.new(status: http_status(e), message: e.message) if retryable_agent_bot_error?(e)
|
||||
|
||||
handle_failure(e)
|
||||
end
|
||||
|
||||
@@ -32,17 +40,19 @@ class Webhooks::Trigger
|
||||
|
||||
def perform_request
|
||||
body = @payload.to_json
|
||||
RestClient::Request.execute(
|
||||
SafeFetch.fetch(
|
||||
@url,
|
||||
method: :post,
|
||||
url: @url,
|
||||
payload: body,
|
||||
body: body,
|
||||
headers: request_headers(body),
|
||||
timeout: webhook_timeout
|
||||
)
|
||||
open_timeout: webhook_timeout,
|
||||
read_timeout: webhook_timeout,
|
||||
validate_content_type: false
|
||||
) { |_response| nil }
|
||||
end
|
||||
|
||||
def request_headers(body)
|
||||
headers = { content_type: :json, accept: :json }
|
||||
headers = { 'Content-Type' => 'application/json', 'Accept' => 'application/json' }
|
||||
headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present?
|
||||
if @secret.present?
|
||||
ts = Time.now.to_i.to_s
|
||||
@@ -111,4 +121,14 @@ class Webhooks::Trigger
|
||||
|
||||
timeout&.positive? ? timeout : 5
|
||||
end
|
||||
|
||||
def retryable_agent_bot_error?(error)
|
||||
@webhook_type == :agent_bot_webhook && RETRYABLE_AGENT_BOT_STATUSES.include?(http_status(error))
|
||||
end
|
||||
|
||||
def http_status(error)
|
||||
return unless error.is_a?(SafeFetch::HttpError)
|
||||
|
||||
error.message.to_s[/\A(\d{3})\b/, 1]&.to_i
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
id,name,email,identifier,phone_number,ip_address,custom_attribute_1,custom_attribute_2
|
||||
1,Clarice Uzzell,cuzzell0@mozilla.org,bb4e11cd-0f23-49da-a123-dcc1fec6852c,+498963648018,70.61.11.201,Random-value-1,Random-value-1
|
||||
2,Marieann Creegan,mcreegan1@cornell.edu,e60bab4c-9fbb-47eb-8f75-42025b789c47,+15417543010,168.186.4.241,Random-value0,Random-value0
|
||||
3,Nancey Windibank,nwindibank2@bluehost.com,f793e813-4210-4bf3-a812-711418de25d2,+15417543011,73.44.41.59,Random-value1,Random-value1
|
||||
4,Sibel Stennine,sstennine3@yellowbook.com,d6e35a2d-d093-4437-a577-7df76316b937,+15417543011,115.249.27.155,Random-value2,Random-value2
|
||||
5,Tina O'Lunney,tolunney4@si.edu,3540d40a-5567-4f28-af98-5583a7ddbc56,+15417543011,219.181.212.8,Random-value3,Random-value3
|
||||
6,Quinn Neve,qneve5@army.mil,ba0e1bf0-c74b-41ce-8a2d-0b08fa0e5aa5,+15417543011,231.210.115.166,Random-value4,Random-value4
|
||||
7,Karylin Gaunson,kgaunson6@tripod.com,d24cac79-c81b-4b84-a33e-0441b7c6a981,+15417543011,160.189.41.11,Random-value5,Random-value5
|
||||
8,Jamison Shenton,jshenton7@upenn.edu,29a7a8c0-c7f7-4af9-852f-761b1a784a7a,+15417543011,53.94.18.201,Random-value6,Random-value6
|
||||
9,Gavan Threlfall,gthrelfall8@spotify.com,847d4943-ddb5-47cc-8008-ed5092c675c5,+15417543011,18.87.247.249,Random-value7,Random-value7
|
||||
10,Katina Hemmingway,khemmingway9@ameblo.jp,8f0b5efd-b6a8-4f1e-a1e3-b0ea8c9e3048,+15417543011,25.191.96.124,Random-value8,Random-value8
|
||||
11,Jillian Deinhard,jdeinharda@canalblog.com,bd952787-1b05-411f-9975-b916ec0950cc,+15417543011,11.211.174.93,Random-value9,Random-value9
|
||||
12,Blake Finden,bfindenb@wsj.com,12c95613-e49d-4fa2-86fb-deabb6ebe600,+15417543011,47.26.205.153,Random-value10,Random-value10
|
||||
13,Liane Maxworthy,lmaxworthyc@un.org,36b68e4c-40d6-4e09-bf59-7db3b27b18f0,+15417543011,157.196.34.166,Random-value11,Random-value11
|
||||
14,Martynne Ledley,mledleyd@sourceforge.net,1856bceb-cb36-415c-8ffc-0527f3f750d8,+15417543011,109.231.152.148,Random-value12,Random-value12
|
||||
15,Katharina Ruffli,krufflie@huffingtonpost.com,604de5c9-b154-4279-8978-41fb71f0f773,+15417543011,20.43.146.179,Random-value13,Random-value13
|
||||
16,Tucker Simmance,tsimmancef@bbc.co.uk,0a8fc3a7-4986-4a51-a503-6c7f974c90ad,+15417543011,179.76.226.171,Random-value14,Random-value14
|
||||
17,Wenona Martinson,wmartinsong@census.gov,0e5ea6e3-6824-4e78-a6f5-672847eafa17,+15417543011,92.243.194.160,Random-value15,Random-value15
|
||||
18,Gretna Vedyasov,gvedyasovh@lycos.com,6becf55b-a7b5-48f6-8788-b89cae85b066,+15417543011,25.22.86.101,Random-value16,Random-value16
|
||||
19,Lurline Abdon,labdoni@archive.org,afa9429f-9034-4b06-9efa-980e01906ebf,+15417543011,150.249.116.118,Random-value17,Random-value17
|
||||
20,Fiann Norcliff,fnorcliffj@istockphoto.com,59f72dec-14ba-4d6e-b17c-0d962e69ffac,+15417543011,237.167.197.197,Random-value18,Random-value18
|
||||
21,Zed Linn,zlinnk@phoca.cz,95f7bc56-be92-4c9c-ad58-eff3e63c7bea,+15417543011,88.102.64.113,Random-value19,Random-value19
|
||||
22,Averyl Simyson,asimysonl@livejournal.com,bde1fe59-c9bd-440c-bb39-79fe61dac1d1,+15417543011,141.248.89.29,Random-value20,Random-value20
|
||||
23,Camella Blackadder,cblackadderm@nifty.com,0c981752-5857-487c-b9b5-5d0253df740a,+15417543011,118.123.138.115,Random-value21,Random-value21
|
||||
24,Aurie Spatig,aspatign@printfriendly.com,4cf22bfb-2c3f-41d1-9993-6e3758e457ba,+15417543011,157.45.102.235,Random-value22,Random-value22
|
||||
25,Adrienne Bellard,abellardo@cnn.com,f10f9b8d-38ac-4e17-8a7d-d2e6a055f944,+15417543011,170.73.198.47,Random-value23,Random-value23
|
||||
id,name,email,identifier,phone_number,ip_address,company_name,custom_attribute_1,custom_attribute_2
|
||||
1,Clarice Uzzell,cuzzell0@mozilla.org,bb4e11cd-0f23-49da-a123-dcc1fec6852c,+498963648018,70.61.11.201,Acme Inc,Random-value-1,Random-value-1
|
||||
2,Marieann Creegan,mcreegan1@cornell.edu,e60bab4c-9fbb-47eb-8f75-42025b789c47,+15417543010,168.186.4.241,Acme Inc,Random-value0,Random-value0
|
||||
3,Nancey Windibank,nwindibank2@bluehost.com,f793e813-4210-4bf3-a812-711418de25d2,+15417543011,73.44.41.59,Acme Inc,Random-value1,Random-value1
|
||||
4,Sibel Stennine,sstennine3@yellowbook.com,d6e35a2d-d093-4437-a577-7df76316b937,+15417543011,115.249.27.155,Acme Inc,Random-value2,Random-value2
|
||||
5,Tina O'Lunney,tolunney4@si.edu,3540d40a-5567-4f28-af98-5583a7ddbc56,+15417543011,219.181.212.8,Acme Inc,Random-value3,Random-value3
|
||||
6,Quinn Neve,qneve5@army.mil,ba0e1bf0-c74b-41ce-8a2d-0b08fa0e5aa5,+15417543011,231.210.115.166,Acme Inc,Random-value4,Random-value4
|
||||
7,Karylin Gaunson,kgaunson6@tripod.com,d24cac79-c81b-4b84-a33e-0441b7c6a981,+15417543011,160.189.41.11,Acme Inc,Random-value5,Random-value5
|
||||
8,Jamison Shenton,jshenton7@upenn.edu,29a7a8c0-c7f7-4af9-852f-761b1a784a7a,+15417543011,53.94.18.201,Acme Inc,Random-value6,Random-value6
|
||||
9,Gavan Threlfall,gthrelfall8@spotify.com,847d4943-ddb5-47cc-8008-ed5092c675c5,+15417543011,18.87.247.249,Acme Inc,Random-value7,Random-value7
|
||||
10,Katina Hemmingway,khemmingway9@ameblo.jp,8f0b5efd-b6a8-4f1e-a1e3-b0ea8c9e3048,+15417543011,25.191.96.124,Acme Inc,Random-value8,Random-value8
|
||||
11,Jillian Deinhard,jdeinharda@canalblog.com,bd952787-1b05-411f-9975-b916ec0950cc,+15417543011,11.211.174.93,Acme Inc,Random-value9,Random-value9
|
||||
12,Blake Finden,bfindenb@wsj.com,12c95613-e49d-4fa2-86fb-deabb6ebe600,+15417543011,47.26.205.153,Acme Inc,Random-value10,Random-value10
|
||||
13,Liane Maxworthy,lmaxworthyc@un.org,36b68e4c-40d6-4e09-bf59-7db3b27b18f0,+15417543011,157.196.34.166,Acme Inc,Random-value11,Random-value11
|
||||
14,Martynne Ledley,mledleyd@sourceforge.net,1856bceb-cb36-415c-8ffc-0527f3f750d8,+15417543011,109.231.152.148,Acme Inc,Random-value12,Random-value12
|
||||
15,Katharina Ruffli,krufflie@huffingtonpost.com,604de5c9-b154-4279-8978-41fb71f0f773,+15417543011,20.43.146.179,Acme Inc,Random-value13,Random-value13
|
||||
16,Tucker Simmance,tsimmancef@bbc.co.uk,0a8fc3a7-4986-4a51-a503-6c7f974c90ad,+15417543011,179.76.226.171,Acme Inc,Random-value14,Random-value14
|
||||
17,Wenona Martinson,wmartinsong@census.gov,0e5ea6e3-6824-4e78-a6f5-672847eafa17,+15417543011,92.243.194.160,Acme Inc,Random-value15,Random-value15
|
||||
18,Gretna Vedyasov,gvedyasovh@lycos.com,6becf55b-a7b5-48f6-8788-b89cae85b066,+15417543011,25.22.86.101,Acme Inc,Random-value16,Random-value16
|
||||
19,Lurline Abdon,labdoni@archive.org,afa9429f-9034-4b06-9efa-980e01906ebf,+15417543011,150.249.116.118,Acme Inc,Random-value17,Random-value17
|
||||
20,Fiann Norcliff,fnorcliffj@istockphoto.com,59f72dec-14ba-4d6e-b17c-0d962e69ffac,+15417543011,237.167.197.197,Acme Inc,Random-value18,Random-value18
|
||||
21,Zed Linn,zlinnk@phoca.cz,95f7bc56-be92-4c9c-ad58-eff3e63c7bea,+15417543011,88.102.64.113,Acme Inc,Random-value19,Random-value19
|
||||
22,Averyl Simyson,asimysonl@livejournal.com,bde1fe59-c9bd-440c-bb39-79fe61dac1d1,+15417543011,141.248.89.29,Acme Inc,Random-value20,Random-value20
|
||||
23,Camella Blackadder,cblackadderm@nifty.com,0c981752-5857-487c-b9b5-5d0253df740a,+15417543011,118.123.138.115,Acme Inc,Random-value21,Random-value21
|
||||
24,Aurie Spatig,aspatign@printfriendly.com,4cf22bfb-2c3f-41d1-9993-6e3758e457ba,+15417543011,157.45.102.235,Acme Inc,Random-value22,Random-value22
|
||||
25,Adrienne Bellard,abellardo@cnn.com,f10f9b8d-38ac-4e17-8a7d-d2e6a055f944,+15417543011,170.73.198.47,Acme Inc,Random-value23,Random-value23
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
id,first_name,last_name,email,gender,ip_address,identifier,phone_number,company
|
||||
id,first_name,last_name,email,gender,ip_address,identifier,phone_number,company_name
|
||||
1,Clarice,Uzzell,cuzzell0@mozilla.org,Genderfluid,70.61.11.201,bb4e11cd-0f23-49da-a123-dcc1fec6852c,918080808080,My Company Name
|
||||
2,Marieann,Creegan,mcreegan1@cornell.edu,Genderfluid,168.186.4.241,e60bab4c-9fbb-47eb-8f75-42025b789c47,+918080808081
|
||||
3,Nancey,Windibank,nwindibank2@bluehost.com,Agender,73.44.41.59,f793e813-4210-4bf3-a812-711418de25d2,+918080808082
|
||||
|
||||
|
@@ -52,6 +52,11 @@ RSpec.describe AccountBuilder do
|
||||
.and change(User, :count).by(1)
|
||||
.and change(AccountUser, :count).by(1)
|
||||
end
|
||||
|
||||
it 'initializes the onboarding step to account_details' do
|
||||
_user, account = account_builder.perform
|
||||
expect(account.custom_attributes['onboarding_step']).to eq('account_details')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -56,7 +56,7 @@ RSpec.describe AgentBuilder, type: :model do
|
||||
|
||||
it 'creates a user with default values' do
|
||||
user = agent_builder.perform
|
||||
expect(user.name).to eq('')
|
||||
expect(user.name).to eq(email.split('@').first)
|
||||
expect(AccountUser.find_by(user: user).role).to eq('agent')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -101,7 +101,7 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
end
|
||||
|
||||
it 'returns all contacts with company name desc order' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=-company",
|
||||
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=-company_name",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
@@ -112,7 +112,7 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
end
|
||||
|
||||
it 'returns all contacts with company name asc order with null values at last' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=-company",
|
||||
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=-company_name",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
|
||||
@@ -180,6 +180,18 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
|
||||
expect(portal.archived).to be_truthy
|
||||
end
|
||||
|
||||
it 'does not raise when blob_id is an integer (existing logo re-sent by frontend)' do
|
||||
portal.logo.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
|
||||
params: { portal: { name: 'updated_name' }, blob_id: portal.logo.blob.id },
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['name']).to eq('updated_name')
|
||||
expect(portal.reload.logo).to be_attached
|
||||
end
|
||||
|
||||
it 'clears associated web widget when inbox selection is blank' do
|
||||
web_widget_inbox = create(:inbox, account: account)
|
||||
portal.update!(channel_web_widget: web_widget_inbox.channel)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user