Merge branch 'develop' into fix/mexico-whatsapp-phone-normalization

This commit is contained in:
Muhsin Keloth
2026-04-28 15:38:09 +04:00
committed by GitHub
118 changed files with 2283 additions and 447 deletions
+1
View File
@@ -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
+4
View File
@@ -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)
+5 -1
View File
@@ -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
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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'"
+2 -2
View File
@@ -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;
}, {});
@@ -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',
@@ -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();
+24 -6
View File
@@ -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',
+2 -2
View File
@@ -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
View File
@@ -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
+11
View File
@@ -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
+29 -1
View File
@@ -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?
+2 -2
View File
@@ -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']
+8
View File
@@ -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
+1
View File
@@ -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,
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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) }
+1
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -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
-1
View File
@@ -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:"
-1
View File
@@ -57,5 +57,4 @@ ja:
not_found: "見つかりませんでした"
not_locked: "はロックされていません"
not_saved:
one: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
other: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
-1
View File
@@ -57,5 +57,4 @@ ko:
not_found: "찾을 수 없습니다"
not_locked: "잠겨 있지 않습니다"
not_saved:
one: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
other: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
-1
View File
@@ -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:"
-1
View File
@@ -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:"
-1
View File
@@ -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}:"
-1
View File
@@ -57,5 +57,4 @@ zh_CN:
not_found: "找不到"
not_locked: "未锁定"
not_saved:
one: "%{count} 个错误禁止保存 %{resource}"
other: "%{count} 个错误禁止保存 %{resource}"
-1
View File
@@ -57,5 +57,4 @@ zh_TW:
not_found: "找不到。"
not_locked: "並未被鎖定。"
not_saved:
one: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
other: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
-4
View File
@@ -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'
-4
View File
@@ -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'
-4
View File
@@ -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: '기본 정책'
-4
View File
@@ -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'
-4
View File
@@ -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'
-4
View File
@@ -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'
-4
View File
@@ -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'
-4
View File
@@ -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
View File
@@ -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
+6 -2
View File
@@ -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
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+75
View File
@@ -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
+116
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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
+26 -26
View File
@@ -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 id name email identifier phone_number ip_address company_name custom_attribute_1 custom_attribute_2
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
3 2 Marieann Creegan mcreegan1@cornell.edu e60bab4c-9fbb-47eb-8f75-42025b789c47 +15417543010 168.186.4.241 Acme Inc Random-value0 Random-value0
4 3 Nancey Windibank nwindibank2@bluehost.com f793e813-4210-4bf3-a812-711418de25d2 +15417543011 73.44.41.59 Acme Inc Random-value1 Random-value1
5 4 Sibel Stennine sstennine3@yellowbook.com d6e35a2d-d093-4437-a577-7df76316b937 +15417543011 115.249.27.155 Acme Inc Random-value2 Random-value2
6 5 Tina O'Lunney tolunney4@si.edu 3540d40a-5567-4f28-af98-5583a7ddbc56 +15417543011 219.181.212.8 Acme Inc Random-value3 Random-value3
7 6 Quinn Neve qneve5@army.mil ba0e1bf0-c74b-41ce-8a2d-0b08fa0e5aa5 +15417543011 231.210.115.166 Acme Inc Random-value4 Random-value4
8 7 Karylin Gaunson kgaunson6@tripod.com d24cac79-c81b-4b84-a33e-0441b7c6a981 +15417543011 160.189.41.11 Acme Inc Random-value5 Random-value5
9 8 Jamison Shenton jshenton7@upenn.edu 29a7a8c0-c7f7-4af9-852f-761b1a784a7a +15417543011 53.94.18.201 Acme Inc Random-value6 Random-value6
10 9 Gavan Threlfall gthrelfall8@spotify.com 847d4943-ddb5-47cc-8008-ed5092c675c5 +15417543011 18.87.247.249 Acme Inc Random-value7 Random-value7
11 10 Katina Hemmingway khemmingway9@ameblo.jp 8f0b5efd-b6a8-4f1e-a1e3-b0ea8c9e3048 +15417543011 25.191.96.124 Acme Inc Random-value8 Random-value8
12 11 Jillian Deinhard jdeinharda@canalblog.com bd952787-1b05-411f-9975-b916ec0950cc +15417543011 11.211.174.93 Acme Inc Random-value9 Random-value9
13 12 Blake Finden bfindenb@wsj.com 12c95613-e49d-4fa2-86fb-deabb6ebe600 +15417543011 47.26.205.153 Acme Inc Random-value10 Random-value10
14 13 Liane Maxworthy lmaxworthyc@un.org 36b68e4c-40d6-4e09-bf59-7db3b27b18f0 +15417543011 157.196.34.166 Acme Inc Random-value11 Random-value11
15 14 Martynne Ledley mledleyd@sourceforge.net 1856bceb-cb36-415c-8ffc-0527f3f750d8 +15417543011 109.231.152.148 Acme Inc Random-value12 Random-value12
16 15 Katharina Ruffli krufflie@huffingtonpost.com 604de5c9-b154-4279-8978-41fb71f0f773 +15417543011 20.43.146.179 Acme Inc Random-value13 Random-value13
17 16 Tucker Simmance tsimmancef@bbc.co.uk 0a8fc3a7-4986-4a51-a503-6c7f974c90ad +15417543011 179.76.226.171 Acme Inc Random-value14 Random-value14
18 17 Wenona Martinson wmartinsong@census.gov 0e5ea6e3-6824-4e78-a6f5-672847eafa17 +15417543011 92.243.194.160 Acme Inc Random-value15 Random-value15
19 18 Gretna Vedyasov gvedyasovh@lycos.com 6becf55b-a7b5-48f6-8788-b89cae85b066 +15417543011 25.22.86.101 Acme Inc Random-value16 Random-value16
20 19 Lurline Abdon labdoni@archive.org afa9429f-9034-4b06-9efa-980e01906ebf +15417543011 150.249.116.118 Acme Inc Random-value17 Random-value17
21 20 Fiann Norcliff fnorcliffj@istockphoto.com 59f72dec-14ba-4d6e-b17c-0d962e69ffac +15417543011 237.167.197.197 Acme Inc Random-value18 Random-value18
22 21 Zed Linn zlinnk@phoca.cz 95f7bc56-be92-4c9c-ad58-eff3e63c7bea +15417543011 88.102.64.113 Acme Inc Random-value19 Random-value19
23 22 Averyl Simyson asimysonl@livejournal.com bde1fe59-c9bd-440c-bb39-79fe61dac1d1 +15417543011 141.248.89.29 Acme Inc Random-value20 Random-value20
24 23 Camella Blackadder cblackadderm@nifty.com 0c981752-5857-487c-b9b5-5d0253df740a +15417543011 118.123.138.115 Acme Inc Random-value21 Random-value21
25 24 Aurie Spatig aspatign@printfriendly.com 4cf22bfb-2c3f-41d1-9993-6e3758e457ba +15417543011 157.45.102.235 Acme Inc Random-value22 Random-value22
26 25 Adrienne Bellard abellardo@cnn.com f10f9b8d-38ac-4e17-8a7d-d2e6a055f944 +15417543011 170.73.198.47 Acme Inc Random-value23 Random-value23
+1 -1
View File
@@ -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
1 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
2 1,Clarice,Uzzell,cuzzell0@mozilla.org,Genderfluid,70.61.11.201,bb4e11cd-0f23-49da-a123-dcc1fec6852c,918080808080,My Company Name
3 2,Marieann,Creegan,mcreegan1@cornell.edu,Genderfluid,168.186.4.241,e60bab4c-9fbb-47eb-8f75-42025b789c47,+918080808081
4 3,Nancey,Windibank,nwindibank2@bluehost.com,Agender,73.44.41.59,f793e813-4210-4bf3-a812-711418de25d2,+918080808082
+5
View File
@@ -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
+1 -1
View File
@@ -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