Compare commits

..
Author SHA1 Message Date
Sojan Jose d3dbf44d57 fix: reserve contact company name attribute 2026-04-27 17:11:46 +05:30
Sojan Jose e2c58a1769 fix: guard company condition migration payloads 2026-04-27 17:03:45 +05:30
Sojan JoseandGitHub 3f72bbf286 Merge branch 'develop' into fix/contact-import-company-name-field 2026-04-27 16:47:32 +05:30
Sojan Jose b1fc0c20fc fix: standardize contact import company name 2026-04-27 16:30:44 +05:30
Sojan JoseandGitHub 3c34830bd8 Merge branch 'develop' into fix/contact-import-company-name-field 2026-04-27 14:40:42 +05:30
Mrsandeep27andClaude eecb4a999d fix: store contact CSV import company under additional_attributes[:company_name]
The rest of the product reads the contact company from
additional_attributes[:company_name] (Contact.order_on_company_name scope,
ContactInfo.vue, ContactForm.vue, ContactSortMenu, widget contacts store).
DataImport::ContactManager was writing to additional_attributes[:company]
instead, so imported values silently never appeared in the Company field.

- Write to additional_attributes[:company_name]
- Accept either `company` or `company_name` CSV columns so existing
  import templates keep working (`company_name` wins when both are set).
- Update the existing specs to assert against company_name and add a
  new spec that verifies the company_name CSV column path.

Closes #14096
Revives the never-fully-resolved #9907.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-20 00:46:04 +05:30
87 changed files with 330 additions and 4119 deletions
+1 -5
View File
@@ -44,11 +44,7 @@ class AccountBuilder
end
def create_account
@account = Account.create!(
name: account_name,
locale: I18n.locale,
custom_attributes: { 'onboarding_step' => 'account_details' }
)
@account = Account.create!(name: account_name, locale: I18n.locale)
Current.account = @account
end
@@ -61,8 +61,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def process_attached_logo
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
@portal.logo.attach(blob) if blob
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_signed(blob_id)
@portal.logo.attach(blob)
end
private
@@ -58,7 +58,6 @@ 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
@@ -72,10 +71,9 @@ class Api::V1::AccountsController < Api::BaseController
private
def enqueue_branding_enrichment
email = account_params[:email].presence || @user&.email
return if email.blank?
return if account_params[:email].blank?
Account::BrandingEnrichmentJob.perform_later(@account.id, email)
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[: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
@@ -111,7 +109,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
params.permit(:industry, :company_size, :timezone)
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_password, only: [:destroy]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
before_action :validate_password, only: [:destroy]
def show; end
@@ -48,8 +48,7 @@ class Api::V1::Profile::MfaController < Api::BaseController
def validate_otp
authenticated = Mfa::AuthenticationService.new(
user: current_user,
otp_code: mfa_params[:otp_code],
backup_code: mfa_params[:backup_code]
otp_code: mfa_params[:otp_code]
).authenticate
return if authenticated
@@ -64,6 +63,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
end
def mfa_params
params.permit(:otp_code, :backup_code, :password)
params.permit(:otp_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').includes(:permissible)
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
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(hardbreaks: true).to_s
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
# Raise a standard error if any email address is invalid
+3 -3
View File
@@ -59,6 +59,7 @@ export default {
isRTL: 'accounts/isRTL',
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
}),
hideOnOnboardingView() {
return !isOnOnboardingView(this.$route);
@@ -106,9 +107,8 @@ export default {
this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId,
});
const account = this.getAccount(this.currentAccountId);
const { locale, latest_chatwoot_version: latestChatwootVersion } =
account;
this.getAccount(this.currentAccountId);
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"
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
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, backupCode } = {}) {
disable(password, otpCode) {
return axios.delete(this.url, {
data: { password, otp_code: otpCode, backup_code: backupCode },
data: { password, otp_code: otpCode },
});
}
@@ -51,6 +51,7 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
@@ -135,16 +135,6 @@ 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,7 +23,6 @@ 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,10 +30,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
readonly: {
type: Boolean,
default: false,
},
focusOnMount: {
type: Boolean,
default: false,
@@ -86,7 +82,6 @@ onMounted(() => {
defineExpose({
focus: () => inlineInputRef.value?.focus(),
blur: () => inlineInputRef.value?.blur(),
});
</script>
@@ -111,7 +106,6 @@ 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,8 +153,3 @@ 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,7 +33,6 @@ 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,
};
}
@@ -195,10 +194,6 @@ 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 });
@@ -387,7 +387,6 @@
"IDENTIFIER": "Identifier",
"COUNTRY": "Country",
"CITY": "City",
"COMPANY": "Company",
"CREATED_AT": "Created at",
"LAST_ACTIVITY": "Last activity",
"REFERER_LINK": "Referer link",
@@ -39,7 +39,6 @@ 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 {
@@ -84,6 +83,5 @@ export default {
...whatsappTemplates,
...contentTemplates,
...mfa,
...onboarding,
...yearInReview,
};
@@ -51,14 +51,10 @@
},
"DISABLE": {
"TITLE": "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.",
"DESCRIPTION": "You'll need to enter your password and a verification 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",
@@ -1,34 +0,0 @@
{
"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,6 +49,7 @@ const navigateTo = computed(() => {
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
@@ -53,14 +53,6 @@ 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',
@@ -132,10 +124,6 @@ export const filterAttributeGroups = [
key: 'city',
i18nKey: 'CITY',
},
{
key: 'company_name',
i18nKey: 'COMPANY',
},
{
key: 'created_at',
i18nKey: 'CREATED_AT',
@@ -12,7 +12,6 @@ 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: [
@@ -32,14 +31,6 @@ 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',
@@ -1,417 +0,0 @@
<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>
@@ -1,18 +0,0 @@
<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>
@@ -1,37 +0,0 @@
<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>
@@ -1,120 +0,0 @@
<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>
@@ -1,49 +0,0 @@
<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>
@@ -1,68 +0,0 @@
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' },
];
@@ -31,8 +31,6 @@ const backupCodesDialogRef = ref(null);
// Form values
const disablePassword = ref('');
const disableOtpCode = ref('');
const disableBackupCode = ref('');
const useBackupCodeToDisable = ref(false);
const regenerateOtpCode = ref('');
// Utility functions
@@ -56,17 +54,10 @@ const downloadBackupCodes = () => {
const handleDisableMfa = async () => {
emit('disableMfa', {
password: disablePassword.value,
otpCode: useBackupCodeToDisable.value ? '' : disableOtpCode.value,
backupCode: useBackupCodeToDisable.value ? disableBackupCode.value : '',
otpCode: disableOtpCode.value,
});
};
const toggleDisableMethod = () => {
useBackupCodeToDisable.value = !useBackupCodeToDisable.value;
disableOtpCode.value = '';
disableBackupCode.value = '';
};
const handleRegenerateBackupCodes = async () => {
emit('regenerateBackupCodes', {
otpCode: regenerateOtpCode.value,
@@ -77,8 +68,6 @@ const handleRegenerateBackupCodes = async () => {
const resetDisableForm = () => {
disablePassword.value = '';
disableOtpCode.value = '';
disableBackupCode.value = '';
useBackupCodeToDisable.value = false;
disableDialogRef.value?.close();
};
@@ -168,32 +157,12 @@ 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, backupCode }) => {
const disableMfa = async ({ password, otpCode }) => {
try {
await mfaAPI.disable(password, { otpCode, backupCode });
await mfaAPI.disable(password, otpCode);
mfaEnabled.value = false;
backupCodesGenerated.value = false;
managementActionsRef.value?.resetDisableForm();
+6 -24
View File
@@ -4,15 +4,13 @@ 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 = async (to, next) => {
export const validateAuthenticateRoutePermission = (to, next) => {
const { isLoggedIn, getCurrentUser: user } = store.getters;
if (!isLoggedIn) {
@@ -29,25 +27,8 @@ export const validateAuthenticateRoutePermission = async (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) {
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`));
return next(frontendURL(`accounts/${accountId}/dashboard`));
}
const nextRoute = validateLoggedInRoutes(to, store.getters.getCurrentUser);
@@ -57,14 +38,15 @@ export const validateAuthenticateRoutePermission = async (to, next) => {
export const initalizeRouter = () => {
const userAuthentication = store.dispatch('setUser');
router.beforeEach(async (to, _from, next) => {
router.beforeEach((to, _from, next) => {
AnalyticsHelper.page(to.name || '', {
path: to.path,
name: to.name,
});
await userAuthentication;
await validateAuthenticateRoutePermission(to, next, store);
userAuthentication.then(() => {
return validateAuthenticateRoutePermission(to, next, store);
});
});
};
@@ -12,9 +12,7 @@ vi.mock('../store', () => ({
id: null,
accounts: [],
},
'accounts/getAccount': () => ({}),
},
dispatch: vi.fn(() => Promise.resolve()),
},
}));
@@ -62,14 +60,14 @@ describe('#validateAuthenticateRoutePermission', () => {
});
describe('when route is not accessible to current user', () => {
it('should redirect to dashboard', async () => {
it('should redirect to dashboard', () => {
const to = {
name: 'general_settings_index',
params: { accountId: 1 },
meta: { permissions: ['administrator'] },
};
await validateAuthenticateRoutePermission(to, next);
validateAuthenticateRoutePermission(to, next);
expect(next).toHaveBeenCalledWith('/app/accounts/1/dashboard');
});
@@ -92,14 +90,14 @@ describe('#validateAuthenticateRoutePermission', () => {
};
});
it('should go to the intended route', async () => {
it('should go to the intended route', () => {
const to = {
name: 'general_settings_index',
params: { accountId: 1 },
meta: { permissions: ['administrator'] },
};
await validateAuthenticateRoutePermission(to, next);
validateAuthenticateRoutePermission(to, next);
expect(next).toHaveBeenCalledWith();
});
@@ -54,19 +54,18 @@ export const getters = {
};
export const actions = {
get: async ({ commit }, { silent } = {}) => {
if (!silent) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
}
get: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
try {
const response = await AccountAPI.get();
commit(types.default.ADD_ACCOUNT, response.data);
} catch {
// silent failure
} finally {
if (!silent) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: false });
}
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingItem: false,
});
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingItem: false,
});
}
},
update: async ({ commit }, { options, ...updateObj }) => {
@@ -269,20 +269,6 @@ 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,46 +57,4 @@ 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,7 +4,6 @@ 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 Webhooks::Trigger::RetryableError, wait: 3.seconds, attempts: 3 do |job, error|
retry_on RestClient::TooManyRequests, RestClient::InternalServerError, 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 Webhooks::Trigger::RetryableError => e
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name} payload=#{payload.to_json}")
raise
end
+6 -17
View File
@@ -23,24 +23,13 @@ class HookJob < MutexApplicationJob
private
def process_slack_integration(hook, event_name, event_data)
return unless ['message.created'].include?(event_name)
message = event_data[:message]
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)
if message.attachments.blank?
::SendOnSlackJob.perform_later(message, hook)
else
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
end
end
-11
View File
@@ -1,11 +0,0 @@
class UpdateSlackMessageJob < MutexApplicationJob
queue_as :medium
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
def perform(message, hook)
key = format(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id)
with_lock(key) do
Integrations::Slack::UpdateSlackMessageService.new(message: message, hook: hook).perform
end
end
end
+1 -29
View File
@@ -1,9 +1,5 @@
class Webhooks::WhatsappEventsJob < MutexApplicationJob
class Webhooks::WhatsappEventsJob < ApplicationJob
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)
@@ -13,20 +9,6 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
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
@@ -87,16 +69,6 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
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, previous_changes: event.data[:previous_changes])
HookJob.perform_later(hook, event.name, message: message)
end
end
@@ -59,7 +59,7 @@ class HookListener < BaseListener
return false if hook.disabled?
supported_events_map = {
'slack' => ['message.created', 'message.updated'],
'slack' => ['message.created'],
'dialogflow' => ['message.created', 'message.updated'],
'google_translate' => ['message.created'],
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved']
-8
View File
@@ -157,14 +157,6 @@ 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,7 +32,6 @@ 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
@@ -19,7 +19,7 @@ module SortHandler
end
def sort_on_waiting_since(sort_direction = :asc)
order(generate_sql_query("(waiting_since IS NULL), waiting_since #{sort_direction.to_s.upcase}, created_at ASC"))
order(generate_sql_query("waiting_since #{sort_direction.to_s.upcase} NULLS LAST, created_at ASC"))
end
def last_messaged_conversations
@@ -10,9 +10,7 @@ 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.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.onboarding_step resource.custom_attributes['onboarding_step'] if resource.custom_attributes['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,7 +22,6 @@ 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,6 +1,3 @@
json.array! @resources do |resource|
bot = resource.permissible
next if bot.nil?
json.partial! 'platform/api/v1/models/agent_bot', formats: [:json], resource: bot
json.partial! 'platform/api/v1/models/agent_bot', formats: [:json], resource: resource.permissible
end
@@ -2,11 +2,11 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
before_action :check_authorization
before_action :check_cloud_env, only: [:checkout, :limits, :toggle_deletion]
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
def subscription
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
@account.update(custom_attributes: subscription_custom_attributes)
@account.update(custom_attributes: { is_creating_customer: true })
Enterprise::CreateStripeCustomerJob.perform_later(@account)
end
head :no_content
@@ -98,19 +98,6 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
@account.custom_attributes['stripe_customer_id']
end
def subscription_custom_attributes
attributes = @account.custom_attributes.merge('is_creating_customer' => true)
attributes['billing_attribution'] = billing_attribution if billing_attribution.present?
attributes
end
def billing_attribution
{
'datafast_visitor_id' => cookies[:datafast_visitor_id],
'datafast_session_id' => cookies[:datafast_session_id]
}.compact
end
def mark_for_deletion
reason = 'manual_deletion'
@@ -14,8 +14,6 @@ class Enterprise::Billing::HandleStripeEventService
process_subscription_updated
when 'customer.subscription.deleted'
process_subscription_deleted
when 'invoice.paid', 'invoice.payment_succeeded'
process_invoice_paid
else
Rails.logger.debug { "Unhandled event type: #{event.type}" }
end
@@ -76,12 +74,6 @@ class Enterprise::Billing::HandleStripeEventService
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
end
def process_invoice_paid
return if invoice_account.blank?
Enterprise::Billing::TrackPaymentAttributionService.new(account: invoice_account, invoice: invoice).perform
end
def handle_subscription_credits(plan, previous_usage)
current_limits = account.limits || {}
@@ -117,10 +109,6 @@ class Enterprise::Billing::HandleStripeEventService
@subscription ||= @event.data.object
end
def invoice
@invoice ||= @event.data.object
end
def previous_attributes
@previous_attributes ||= JSON.parse((@event.data.previous_attributes || {}).to_json)
end
@@ -144,10 +132,6 @@ class Enterprise::Billing::HandleStripeEventService
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
end
def invoice_account
@invoice_account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", invoice.customer).first
end
def find_plan(plan_id)
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
@@ -1,100 +0,0 @@
class Enterprise::Billing::TrackPaymentAttributionService
pattr_initialize [:account!, :invoice!]
API_ENDPOINT = 'https://datafa.st/api/v1/payments'.freeze
API_KEY_CONFIG = 'DATAFAST_API_KEY'.freeze
ZERO_DECIMAL_CURRENCIES = %w[BIF CLP DJF GNF JPY KMF KRW MGA PYG RWF UGX VND VUV XAF XOF XPF].freeze
def perform
return unless trackable?
response = HTTParty.post(
API_ENDPOINT,
headers: {
'Authorization' => "Bearer #{api_key}",
'Content-Type' => 'application/json'
},
body: payload.to_json,
timeout: 5
)
log_failure("#{response.code} #{response.body}") unless response.success?
rescue StandardError => e
log_failure("#{e.class} - #{e.message}")
end
private
def trackable?
ChatwootApp.chatwoot_cloud? && [api_key, datafast_visitor_id, amount_paid, currency, transaction_id].all?(&:present?)
end
def payload
{
amount: amount,
currency: currency.upcase,
transaction_id: transaction_id,
datafast_visitor_id: datafast_visitor_id,
email: customer_email,
name: customer_name,
customer_id: customer_id,
renewal: renewal?
}.compact
end
def amount
return amount_paid if ZERO_DECIMAL_CURRENCIES.include?(currency.upcase)
amount_paid.to_f / 100
end
def amount_paid
invoice_value('amount_paid')
end
def currency
invoice_value('currency')
end
def transaction_id
invoice_value('id')
end
def customer_id
invoice_value('customer') || account.custom_attributes['stripe_customer_id']
end
def customer_email
invoice_value('customer_email') || account.administrators.first&.email
end
def customer_name
invoice_value('customer_name') || account.name
end
def renewal?
invoice_value('billing_reason') == 'subscription_cycle'
end
def datafast_visitor_id
attribution['datafast_visitor_id']
end
def attribution
account.custom_attributes['billing_attribution'] || {}
end
def api_key
GlobalConfigService.load(API_KEY_CONFIG, nil)
end
def log_failure(message)
Rails.logger.warn("Payment attribution failed for invoice #{transaction_id}: #{message}")
end
def invoice_value(key)
invoice[key]
rescue NoMethodError
nil
end
end
+2 -2
View File
@@ -3,8 +3,8 @@ class ChatwootMarkdownRenderer
@content = content
end
def render_message(hardbreaks: false)
markdown_renderer = BaseMarkdownRenderer.new(options: hardbreaks ? [:HARDBREAKS] : :DEFAULT)
def render_message
markdown_renderer = BaseMarkdownRenderer.new
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough, :autolink])
html = markdown_renderer.render(doc)
render_as_html_safe(html)
@@ -1,152 +0,0 @@
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,7 +43,6 @@ 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
+92 -10
View File
@@ -2,8 +2,6 @@ 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
@@ -21,22 +19,106 @@ module SafeFetch
class HttpError < Error; end
class FileTooLargeError < Error; end
class UnsupportedContentTypeError < Error; end
class UnsupportedMethodError < Error; end
end
require_relative 'safe_fetch/request_options'
require_relative 'safe_fetch/fetcher'
module SafeFetch
def self.fetch(url, **, &)
def self.fetch(url,
max_bytes: nil,
allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES,
allowed_content_types: [])
raise ArgumentError, 'block required' unless block_given?
Fetcher.new(RequestOptions.new(url: url, **)).fetch(&)
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)
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
@@ -1,75 +0,0 @@
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
@@ -1,116 +0,0 @@
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
@@ -1,14 +0,0 @@
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
+10 -30
View File
@@ -1,15 +1,5 @@
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
@@ -25,9 +15,11 @@ class Webhooks::Trigger
def execute
perform_request
rescue StandardError => e
raise RetryableError.new(status: http_status(e), message: e.message) if retryable_agent_bot_error?(e)
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
raise if @webhook_type == :agent_bot_webhook
handle_failure(e)
rescue StandardError => e
handle_failure(e)
end
@@ -40,19 +32,17 @@ class Webhooks::Trigger
def perform_request
body = @payload.to_json
SafeFetch.fetch(
@url,
RestClient::Request.execute(
method: :post,
body: body,
url: @url,
payload: body,
headers: request_headers(body),
open_timeout: webhook_timeout,
read_timeout: webhook_timeout,
validate_content_type: false
) { |_response| nil }
timeout: webhook_timeout
)
end
def request_headers(body)
headers = { 'Content-Type' => 'application/json', 'Accept' => 'application/json' }
headers = { content_type: :json, accept: :json }
headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present?
if @secret.present?
ts = Time.now.to_i.to_s
@@ -121,14 +111,4 @@ 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
-5
View File
@@ -52,11 +52,6 @@ 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
@@ -180,18 +180,6 @@ 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,16 +302,6 @@ 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,
@@ -39,19 +39,6 @@ RSpec.describe 'Platform Agent Bot API', type: :request do
expect(data.length).to eq(1)
expect(data.first['outgoing_url']).to eq(agent_bot.outgoing_url)
end
it 'returns 200 and skips orphaned permissibles when an agent bot has been deleted' do
create(:platform_app_permissible, platform_app: platform_app, permissible: agent_bot)
# Use delete (not destroy!) to bypass dependent: :destroy callbacks so the
# permissible row survives — exactly the orphan scenario described in the issue.
agent_bot.delete
get '/platform/api/v1/agent_bots',
headers: { api_access_token: platform_app.access_token.token }, as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body).to be_empty
end
end
end
@@ -5,10 +5,6 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
let!(:admin) { create(:user, account: account, role: :administrator) }
let!(:agent) { create(:user, account: account, role: :agent) }
before do
allow(GlobalConfig).to receive(:get_value).and_call_original
end
describe 'POST /enterprise/api/v1/accounts/{account.id}/subscription' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -39,21 +35,6 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
expect(account.reload.custom_attributes).to eq({ 'is_creating_customer': true }.with_indifferent_access)
end
it 'stores billing attribution from request cookies' do
post "/enterprise/api/v1/accounts/#{account.id}/subscription",
headers: admin.create_new_auth_token.merge(
'Cookie' => 'datafast_visitor_id=visitor-123; datafast_session_id=session-123'
),
as: :json
expect(account.reload.custom_attributes['billing_attribution']).to eq(
{
'datafast_visitor_id' => 'visitor-123',
'datafast_session_id' => 'session-123'
}
)
end
it 'does not enqueue a job if a job is already enqueued' do
account.update!(custom_attributes: { is_creating_customer: true })
@@ -98,10 +79,6 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
end
context 'when it is an admin and the stripe customer id is not present' do
before do
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
end
it 'returns error' do
post "/enterprise/api/v1/accounts/#{account.id}/checkout",
headers: admin.create_new_auth_token,
@@ -114,10 +91,6 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
end
context 'when it is an admin and the stripe customer is present' do
before do
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
end
it 'calls create session' do
account.update!(custom_attributes: { 'stripe_customer_id': 'cus_random_string' })
@@ -149,8 +122,8 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
context 'when it is an authenticated user' do
before do
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(value: [{ 'name' => 'Hacker' }])
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }])
end
context 'when it is an agent' do
@@ -185,8 +158,8 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
before do
create(:conversation, account: account)
create(:channel_api, account: account)
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(value: [{ 'name' => 'Hacker' }])
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }])
end
it 'returns the limits if the plan is default' do
@@ -279,12 +252,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
let(:stripe_invoice) { Struct.new(:id).new('inv_test123') }
before do
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(
value: [
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
]
)
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
])
end
it 'returns unauthorized for unauthenticated user' do
@@ -370,7 +341,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
context 'when deployment environment is not cloud' do
before do
# Set deployment environment to something other than cloud
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('self_hosted')
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'self_hosted')
end
it 'returns not found' do
@@ -387,7 +358,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
context 'when it is an admin' do
before do
# Create the installation config for cloud environment
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud')
end
it 'marks the account for deletion when action is delete' do
@@ -10,23 +10,25 @@ describe Enterprise::Billing::HandleStripeEventService do
before do
# Create cloud plans configuration
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(
value: [
{ 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] },
{ 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] },
{ 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] }
]
)
create(:installation_config, {
name: 'CHATWOOT_CLOUD_PLANS',
value: [
{ 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] },
{ 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] },
{ 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] }
]
})
InstallationConfig.where(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').first_or_initialize.update!(
value: {
'hacker' => { 'responses' => 0 },
'startups' => { 'responses' => 300 },
'business' => { 'responses' => 500 },
'enterprise' => { 'responses' => 800 }
}
)
create(:installation_config, {
name: 'CAPTAIN_CLOUD_PLAN_LIMITS',
value: {
'hacker' => { 'responses' => 0 },
'startups' => { 'responses' => 300 },
'business' => { 'responses' => 500 },
'enterprise' => { 'responses' => 800 }
}
})
# Setup common subscription mocks
allow(event).to receive(:data).and_return(data)
allow(data).to receive(:object).and_return(subscription)
@@ -131,25 +133,6 @@ describe Enterprise::Billing::HandleStripeEventService do
end
end
describe 'invoice payment handling' do
let(:invoice) { instance_double(Stripe::Invoice, customer: 'cus_123') }
it 'tracks payment attribution on paid invoices' do
allow(event).to receive(:type).and_return('invoice.paid')
allow(data).to receive(:object).and_return(invoice)
attribution_service = instance_double(Enterprise::Billing::TrackPaymentAttributionService, perform: true)
allow(Enterprise::Billing::TrackPaymentAttributionService)
.to receive(:new)
.with(account: account, invoice: invoice)
.and_return(attribution_service)
stripe_event_service.new.perform(event: event)
expect(attribution_service).to have_received(:perform)
end
end
describe 'plan-specific feature management' do
context 'with default plan (Hacker)' do
it 'disables all premium features' do
@@ -1,73 +0,0 @@
require 'rails_helper'
RSpec.describe Enterprise::Billing::TrackPaymentAttributionService do
subject(:service) { described_class.new(account: account, invoice: invoice) }
let(:account) do
create(
:account,
custom_attributes: {
'stripe_customer_id' => 'cus_123',
'billing_attribution' => {
'datafast_visitor_id' => 'visitor-123'
}
}
)
end
let!(:admin) { create(:user, account: account, role: :administrator, email: 'admin@example.com') }
let(:invoice) do
{
'id' => 'in_123',
'amount_paid' => 2900,
'currency' => 'usd',
'customer' => 'cus_123',
'customer_name' => 'Acme Finance',
'billing_reason' => 'subscription_create'
}
end
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
allow(HTTParty).to receive(:post).and_return(instance_double(HTTParty::Response, success?: true))
allow(GlobalConfigService).to receive(:load).with('DATAFAST_API_KEY', nil).and_return('test-key')
end
it 'sends payment attribution to the payment API' do
service.perform
expect(HTTParty).to have_received(:post).with(
'https://datafa.st/api/v1/payments',
headers: {
'Authorization' => 'Bearer test-key',
'Content-Type' => 'application/json'
},
body: {
amount: 29.0,
currency: 'USD',
transaction_id: 'in_123',
datafast_visitor_id: 'visitor-123',
email: admin.email,
name: 'Acme Finance',
customer_id: 'cus_123',
renewal: false
}.to_json,
timeout: 5
)
end
it 'skips the API call when attribution is unavailable' do
account.update!(custom_attributes: { 'stripe_customer_id' => 'cus_123' })
service.perform
expect(HTTParty).not_to have_received(:post)
end
it 'skips the API call when the installation config is unavailable' do
allow(GlobalConfigService).to receive(:load).with('DATAFAST_API_KEY', nil).and_return(nil)
service.perform
expect(HTTParty).not_to have_received(:post)
end
end
+3 -3
View File
@@ -8,7 +8,7 @@ RSpec.describe AgentBots::WebhookJob do
let(:url) { 'https://test.com' }
let(:payload) { { name: 'test' } }
let(:webhook_type) { :agent_bot_webhook }
let(:retryable_error) { Webhooks::Trigger::RetryableError.new(status: 500, message: '500 Internal Server Error') }
let(:retryable_error) { RestClient::InternalServerError.new(nil, 500) }
before do
ActiveJob::Base.queue_adapter = :test
@@ -33,7 +33,7 @@ RSpec.describe AgentBots::WebhookJob do
it 'configures retry handlers for 429 and 500 errors' do
handlers = described_class.rescue_handlers.map(&:first)
expect(handlers).to include('Webhooks::Trigger::RetryableError')
expect(handlers).to include('RestClient::TooManyRequests', 'RestClient::InternalServerError')
end
it 'retries 3 times and handles failure after retries are exhausted' do
@@ -43,7 +43,7 @@ RSpec.describe AgentBots::WebhookJob do
allow(Rails.logger).to receive(:warn)
expect(Webhooks::Trigger).to receive(:execute).exactly(3).times
expect(trigger_instance).to receive(:handle_failure).with(instance_of(Webhooks::Trigger::RetryableError)).once
expect(trigger_instance).to receive(:handle_failure).with(instance_of(RestClient::InternalServerError)).once
expect(Rails.logger).to receive(:warn).with(/AgentBots::WebhookJob/).exactly(3).times
perform_enqueued_jobs { job }
-38
View File
@@ -67,44 +67,6 @@ RSpec.describe HookJob do
end
end
context 'when handleable events like message.updated for slack' do
let(:process_service) { double }
before do
allow(process_service).to receive(:perform)
end
it 'calls UpdateSlackMessageJob when content_attributes changed' do
message = create(:message, account: account, content: 'Pick one', message_type: :outgoing,
content_type: :input_select, content_attributes: { items: [{ title: 'A', value: 'a' }] })
hook = create(:integrations_hook, app_id: 'slack', account: account)
event_data = { message: message, previous_changes: { 'content_attributes' => [{}, { 'submitted_values' => [{ 'title' => 'A' }] }] } }
allow(UpdateSlackMessageJob).to receive(:perform_later).and_return(process_service)
expect(UpdateSlackMessageJob).to receive(:perform_later).with(message, hook)
described_class.perform_now(hook, 'message.updated', event_data)
end
it 'does not call UpdateSlackMessageJob when content_attributes did not change' do
message = create(:message, account: account, content: 'Pick one', message_type: :outgoing,
content_type: :input_select, content_attributes: { items: [{ title: 'A', value: 'a' }] })
hook = create(:integrations_hook, app_id: 'slack', account: account)
event_data = { message: message, previous_changes: { 'status' => %w[sent delivered] } }
expect(UpdateSlackMessageJob).not_to receive(:perform_later)
described_class.perform_now(hook, 'message.updated', event_data)
end
it 'does not call UpdateSlackMessageJob for unsupported content types' do
message = create(:message, account: account, content: 'Hello', message_type: :outgoing, content_type: :text)
hook = create(:integrations_hook, app_id: 'slack', account: account)
event_data = { message: message, previous_changes: { 'content_attributes' => [{}, {}] } }
expect(UpdateSlackMessageJob).not_to receive(:perform_later)
described_class.perform_now(hook, 'message.updated', event_data)
end
end
context 'when processing leadsquared integration' do
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, contact: contact) }
@@ -1,21 +0,0 @@
require 'rails_helper'
RSpec.describe UpdateSlackMessageJob do
let(:account) { create(:account) }
let(:hook) { create(:integrations_hook, app_id: 'slack', account: account) }
let(:message) do
create(:message, account: account, content: 'Pick one', message_type: :outgoing, content_type: :input_select,
content_attributes: { items: [{ title: 'Option A', value: 'a' }] })
end
before do
stub_request(:post, 'https://slack.com/api/chat.update')
end
it 'calls Integrations::Slack::UpdateSlackMessageService' do
service_instance = Integrations::Slack::UpdateSlackMessageService.new(message: message, hook: hook)
expect(Integrations::Slack::UpdateSlackMessageService).to receive(:new).with(message: message, hook: hook).and_return(service_instance)
described_class.perform_now(message, hook)
end
end
@@ -1,245 +0,0 @@
require 'rails_helper'
describe Integrations::Slack::UpdateSlackMessageService do
let(:account) { create(:account) }
let(:channel_email) { create(:channel_email, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, inbox: channel_email.inbox, contact: contact, identifier: '12345.6789') }
let(:hook) { create(:integrations_hook, account: account, reference_id: 'C123') }
let(:slack_client) { double }
before do
allow(Slack::Web::Client).to receive(:new).and_return(slack_client)
end
describe '#perform' do
context 'with input_select' do
it 'updates the Slack message with the submitted response' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_select,
content: 'Pick one',
content_attributes: {
items: [{ title: 'Option A', value: 'a' }, { title: 'Option B', value: 'b' }],
submitted_values: [{ title: 'Option A', value: 'a' }]
},
external_source_id_slack: 'cw-origin-6789.12345'
)
expect(slack_client).to receive(:chat_update).with(
channel: 'C123',
ts: '6789.12345',
text: a_string_including('Pick one', 'Option A')
)
described_class.new(message: message, hook: hook).perform
end
end
context 'with form' do
it 'updates the Slack message with all submitted fields' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :form,
content: 'Please fill this',
content_attributes: {
items: [{ name: 'email', label: 'Email' }, { name: 'company', label: 'Company' }],
submitted_values: [{ name: 'email', value: 'a@example.com' }, { name: 'company', value: 'Acme' }]
},
external_source_id_slack: 'cw-origin-6789.12345'
)
expect(slack_client).to receive(:chat_update).with(
channel: 'C123',
ts: '6789.12345',
text: a_string_including('Please fill this', 'Email', 'a@example.com', 'Company', 'Acme')
)
described_class.new(message: message, hook: hook).perform
end
end
context 'with input_csat' do
it 'updates the Slack message with the CSAT rating and feedback' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_csat,
content: 'Rate us',
content_attributes: {
submitted_values: {
'csat_survey_response' => { 'rating' => 5, 'feedback_message' => 'Great support!' }
}
},
external_source_id_slack: 'cw-origin-6789.12345'
)
expect(slack_client).to receive(:chat_update).with(
channel: 'C123',
ts: '6789.12345',
text: a_string_including('Rate us', 'Rating: 5', 'Great support!')
)
described_class.new(message: message, hook: hook).perform
end
end
context 'with input_email' do
it 'updates the Slack message with the submitted email' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_email,
content: 'Get notified by email',
content_attributes: {
submitted_email: 'user@example.com'
},
external_source_id_slack: 'cw-origin-6789.12345'
)
expect(slack_client).to receive(:chat_update).with(
channel: 'C123',
ts: '6789.12345',
text: a_string_including('Get notified by email', 'user@example.com')
)
described_class.new(message: message, hook: hook).perform
end
end
context 'when there is no submitted response' do
it 'skips the update' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_select,
content: 'Pick one',
content_attributes: {
items: [{ title: 'Option A', value: 'a' }]
},
external_source_id_slack: 'cw-origin-6789.12345'
)
expect(slack_client).not_to receive(:chat_update)
described_class.new(message: message, hook: hook).perform
end
end
context 'when the message was not originated from Chatwoot' do
it 'skips the update' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_select,
content: 'Pick one',
content_attributes: {
items: [{ title: 'Option A', value: 'a' }],
submitted_values: [{ title: 'Option A', value: 'a' }]
},
external_source_id_slack: '6789.12345'
)
expect(slack_client).not_to receive(:chat_update)
described_class.new(message: message, hook: hook).perform
end
end
context 'when hook has no reference_id' do
it 'skips the update' do
hook_without_ref = create(:integrations_hook, account: account, reference_id: nil)
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_select,
content: 'Pick one',
content_attributes: {
items: [{ title: 'Option A', value: 'a' }],
submitted_values: [{ title: 'Option A', value: 'a' }]
},
external_source_id_slack: 'cw-origin-6789.12345'
)
expect(slack_client).not_to receive(:chat_update)
described_class.new(message: message, hook: hook_without_ref).perform
end
end
context 'when Slack API raises an auth error' do
it 'disables the hook and prompts reauthorization' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_select,
content: 'Pick one',
content_attributes: {
items: [{ title: 'Option A', value: 'a' }],
submitted_values: [{ title: 'Option A', value: 'a' }]
},
external_source_id_slack: 'cw-origin-6789.12345'
)
allow(slack_client).to receive(:chat_update).and_raise(Slack::Web::Api::Errors::AccountInactive.new('account_inactive'))
allow(hook).to receive(:prompt_reauthorization!)
described_class.new(message: message, hook: hook).perform
expect(hook).to have_received(:prompt_reauthorization!)
expect(hook.reload.status).to eq('disabled')
end
end
context 'when the original Slack message no longer exists' do
it 'skips gracefully without disabling the hook' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_select,
content: 'Pick one',
content_attributes: {
items: [{ title: 'Option A', value: 'a' }],
submitted_values: [{ title: 'Option A', value: 'a' }]
},
external_source_id_slack: 'cw-origin-6789.12345'
)
allow(slack_client).to receive(:chat_update).and_raise(Slack::Web::Api::Errors::MessageNotFound.new('message_not_found'))
described_class.new(message: message, hook: hook).perform
expect(hook.reload.status).not_to eq('disabled')
end
end
end
end
-129
View File
@@ -80,66 +80,6 @@ RSpec.describe SafeFetch do
expect(result.content_type).to eq('image/png')
end
end
it 'preserves embedded credentials after a same-origin redirect removes userinfo' do
authenticated_url = 'http://user:pass@example.com/protected.png'
initial_url = 'http://example.com/protected.png'
redirect_url = 'http://example.com/public.png'
redirected_headers = nil
stub_request(:get, initial_url)
.with(headers: { 'Authorization' => 'Basic dXNlcjpwYXNz' })
.to_return(
status: 302,
headers: { 'Location' => '/public.png' }
)
stub_request(:get, redirect_url)
.with do |request|
redirected_headers = request.headers.transform_keys(&:downcase)
true
end
.to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
described_class.fetch(authenticated_url) do |result|
expect(result.content_type).to eq('image/png')
end
expect(redirected_headers).to include('authorization' => 'Basic dXNlcjpwYXNz')
end
it 'strips embedded credentials on cross-origin redirects' do
authenticated_url = 'http://user:pass@example.com/protected.png'
initial_url = 'http://example.com/protected.png'
redirect_url = 'https://example.com/public.png'
redirected_headers = nil
stub_request(:get, initial_url)
.with(headers: { 'Authorization' => 'Basic dXNlcjpwYXNz' })
.to_return(
status: 302,
headers: { 'Location' => redirect_url }
)
stub_request(:get, redirect_url)
.with do |request|
redirected_headers = request.headers.transform_keys(&:downcase)
true
end
.to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
described_class.fetch(authenticated_url) do |result|
expect(result.content_type).to eq('image/png')
end
expect(redirected_headers).not_to include('authorization')
end
end
context 'with URL validation' do
@@ -283,75 +223,6 @@ RSpec.describe SafeFetch do
end
end
context 'with custom request options' do
let(:post_body) { { hello: 'world' }.to_json }
let(:headers) do
{
'Authorization' => 'Bearer test-token',
'Content-Type' => 'application/json'
}
end
it 'supports POST requests with custom headers when content-type validation is disabled' do
stub_request(:post, url)
.with(body: post_body, headers: headers)
.to_return(status: 200, body: '', headers: {})
expect do
described_class.fetch(
url,
method: :post,
body: post_body,
headers: headers,
validate_content_type: false
) { nil }
end.not_to raise_error
end
it 'preserves non-credential headers on cross-origin redirects' do
redirect_url = 'https://example.com/image.png'
redirected_headers = nil
headers = {
'Authorization' => 'Bearer test-token',
'Cookie' => 'session=test',
'Content-Type' => 'application/json',
'X-Chatwoot-Delivery' => 'test-uuid',
'X-Chatwoot-Signature' => 'sha256=test-signature'
}
stub_request(:post, url).to_return(
status: 307,
headers: { 'Location' => redirect_url }
)
stub_request(:post, redirect_url)
.with do |request|
redirected_headers = request.headers.transform_keys(&:downcase)
true
end
.to_return(status: 200, body: '', headers: {})
described_class.fetch(
url,
method: :post,
body: post_body,
headers: headers,
validate_content_type: false
) { nil }
expect(redirected_headers).to include(
'content-type' => 'application/json',
'x-chatwoot-delivery' => 'test-uuid',
'x-chatwoot-signature' => 'sha256=test-signature'
)
expect(redirected_headers).not_to include('authorization', 'cookie')
end
it 'raises UnsupportedMethodError for unsupported HTTP methods' do
expect { described_class.fetch(url, method: :options) { nil } }
.to raise_error(described_class::UnsupportedMethodError)
end
end
context 'with body size cap' do
it 'honours a custom max_bytes argument' do
stub_request(:get, url).to_return(
+131 -96
View File
@@ -11,13 +11,10 @@ describe Webhooks::Trigger do
let!(:message) { create(:message, account: account, inbox: inbox, conversation: conversation) }
let(:webhook_type) { :api_inbox_webhook }
let(:url) { 'https://test.com' }
let(:payload) { { hello: :hello } }
let(:fetch_result) { instance_double(SafeFetch::Result) }
let!(:url) { 'https://test.com' }
let(:agent_bot_error_content) { I18n.t('conversations.activity.agent_bot.error_moved_to_open') }
let(:default_timeout) { 5 }
let(:webhook_timeout) { default_timeout }
let(:base_headers) { { 'Content-Type' => 'application/json', 'Accept' => 'application/json' } }
before do
ActiveJob::Base.queue_adapter = :test
@@ -33,23 +30,30 @@ describe Webhooks::Trigger do
describe '#execute' do
it 'triggers webhook' do
expect(SafeFetch).to receive(:fetch).with(
url,
method: :post,
body: payload.to_json,
headers: base_headers,
open_timeout: webhook_timeout,
read_timeout: webhook_timeout,
validate_content_type: false
).and_yield(fetch_result)
payload = { hello: :hello }
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).once
trigger.execute(url, payload, webhook_type)
end
it 'updates message status if webhook fails for message-created event' do
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
expect(SafeFetch).to receive(:fetch).and_raise(SafeFetch::HttpError.new('500 Internal Server Error'))
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed')
end
@@ -57,18 +61,17 @@ describe Webhooks::Trigger do
it 'updates message status if webhook fails for message-updated event' do
payload = { event: 'message_updated', conversation: { id: conversation.id }, id: message.id }
expect(SafeFetch).to receive(:fetch).and_raise(SafeFetch::HttpError.new('500 Internal Server Error'))
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed')
end
it 'treats blocked private webhook URLs as failures' do
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
expect { trigger.execute('http://127.0.0.1/webhook', payload, webhook_type) }
.to change { message.reload.status }.from('sent').to('failed')
end
context 'when webhook type is agent bot' do
let(:webhook_type) { :agent_bot_webhook }
let!(:pending_conversation) { create(:conversation, inbox: inbox, status: :pending, account: account) }
@@ -77,13 +80,16 @@ describe Webhooks::Trigger do
it 'raises 500 errors for retry and does not reopen conversation immediately' do
payload = { event: 'message_created', id: pending_message.id }
expect(SafeFetch).to receive(:fetch).and_raise(SafeFetch::HttpError.new('500 Internal Server Error'))
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::InternalServerError.new(nil, 500)).once
expect { trigger.execute(url, payload, webhook_type) }
.to(raise_error do |error|
expect(error.class.name).to eq('Webhooks::Trigger::RetryableError')
expect(error.status).to eq(500)
end)
expect { trigger.execute(url, payload, webhook_type) }.to raise_error(RestClient::InternalServerError)
expect(pending_conversation.reload.status).to eq('pending')
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
end
@@ -91,13 +97,16 @@ describe Webhooks::Trigger do
it 'raises 429 errors for retry and does not reopen conversation immediately' do
payload = { event: 'message_created', id: pending_message.id }
expect(SafeFetch).to receive(:fetch).and_raise(SafeFetch::HttpError.new('429 Too Many Requests'))
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::TooManyRequests.new(nil, 429)).once
expect { trigger.execute(url, payload, webhook_type) }
.to(raise_error do |error|
expect(error.class.name).to eq('Webhooks::Trigger::RetryableError')
expect(error.status).to eq(429)
end)
expect { trigger.execute(url, payload, webhook_type) }.to raise_error(RestClient::TooManyRequests)
expect(pending_conversation.reload.status).to eq('pending')
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
end
@@ -105,7 +114,14 @@ describe Webhooks::Trigger do
it 'reopens conversation and enqueues activity message if pending' do
payload = { event: 'message_created', id: pending_message.id }
expect(SafeFetch).to receive(:fetch).and_raise(SafeFetch::HttpError.new('404 Not Found'))
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect do
perform_enqueued_jobs do
@@ -123,7 +139,14 @@ describe Webhooks::Trigger do
it 'does not change message status or enqueue activity when conversation is not pending' do
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
expect(SafeFetch).to receive(:fetch).and_raise(SafeFetch::HttpError.new('404 Not Found'))
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect do
trigger.execute(url, payload, webhook_type)
@@ -137,7 +160,14 @@ describe Webhooks::Trigger do
account.update(keep_pending_on_bot_failure: true)
payload = { event: 'message_created', id: pending_message.id }
expect(SafeFetch).to receive(:fetch).and_raise(SafeFetch::HttpError.new('404 Not Found'))
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
trigger.execute(url, payload, webhook_type)
@@ -149,8 +179,14 @@ describe Webhooks::Trigger do
account.update(keep_pending_on_bot_failure: false)
payload = { event: 'message_created', id: pending_message.id }
expect(SafeFetch).to receive(:fetch).and_raise(SafeFetch::HttpError.new('404 Not Found'))
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect do
perform_enqueued_jobs do
trigger.execute(url, payload, webhook_type)
@@ -168,7 +204,14 @@ describe Webhooks::Trigger do
it 'handles 500 without raising for non-agent webhooks' do
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
expect(SafeFetch).to receive(:fetch).and_raise(SafeFetch::HttpError.new('500 Internal Server Error'))
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::InternalServerError.new(nil, 500)).once
expect { trigger.execute(url, payload, webhook_type) }.not_to raise_error
expect(message.reload.status).to eq('failed')
@@ -181,30 +224,20 @@ describe Webhooks::Trigger do
context 'without secret or delivery_id' do
it 'sends only content-type and accept headers' do
expect(SafeFetch).to receive(:fetch).with(
url,
method: :post,
body: body,
headers: base_headers,
open_timeout: webhook_timeout,
read_timeout: webhook_timeout,
validate_content_type: false
).and_yield(fetch_result)
expect(RestClient::Request).to receive(:execute).with(
hash_including(headers: { content_type: :json, accept: :json })
)
trigger.execute(url, payload, webhook_type)
end
end
context 'with delivery_id' do
it 'adds X-Chatwoot-Delivery header' do
expect(SafeFetch).to receive(:fetch) do |received_url, **options, &block|
expect(received_url).to eq(url)
expect(options[:headers]['X-Chatwoot-Delivery']).to eq('test-uuid')
expect(options[:headers]).not_to have_key('X-Chatwoot-Signature')
expect(options[:headers]).not_to have_key('X-Chatwoot-Timestamp')
block.call(fetch_result)
expect(RestClient::Request).to receive(:execute) do |args|
expect(args[:headers]['X-Chatwoot-Delivery']).to eq('test-uuid')
expect(args[:headers]).not_to have_key('X-Chatwoot-Signature')
expect(args[:headers]).not_to have_key('X-Chatwoot-Timestamp')
end
trigger.execute(url, payload, webhook_type, delivery_id: 'test-uuid')
end
end
@@ -213,45 +246,38 @@ describe Webhooks::Trigger do
let(:secret) { 'test-secret' }
it 'adds X-Chatwoot-Timestamp header' do
expect(SafeFetch).to receive(:fetch) do |_received_url, **options, &block|
expect(options[:headers]['X-Chatwoot-Timestamp']).to match(/\A\d+\z/)
block.call(fetch_result)
expect(RestClient::Request).to receive(:execute) do |args|
expect(args[:headers]['X-Chatwoot-Timestamp']).to match(/\A\d+\z/)
end
trigger.execute(url, payload, webhook_type, secret: secret)
end
it 'adds X-Chatwoot-Signature header with correct HMAC' do
expect(SafeFetch).to receive(:fetch) do |_received_url, **options, &block|
ts = options[:headers]['X-Chatwoot-Timestamp']
expect(RestClient::Request).to receive(:execute) do |args|
ts = args[:headers]['X-Chatwoot-Timestamp']
expected_sig = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, "#{ts}.#{body}")}"
expect(options[:headers]['X-Chatwoot-Signature']).to eq(expected_sig)
block.call(fetch_result)
expect(args[:headers]['X-Chatwoot-Signature']).to eq(expected_sig)
end
trigger.execute(url, payload, webhook_type, secret: secret)
end
it 'signs timestamp.body not just body' do
expect(SafeFetch).to receive(:fetch) do |_received_url, **options, &block|
expect(RestClient::Request).to receive(:execute) do |args|
args[:headers]['X-Chatwoot-Timestamp']
wrong_sig = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
expect(options[:headers]['X-Chatwoot-Signature']).not_to eq(wrong_sig)
block.call(fetch_result)
expect(args[:headers]['X-Chatwoot-Signature']).not_to eq(wrong_sig)
end
trigger.execute(url, payload, webhook_type, secret: secret)
end
end
context 'with both secret and delivery_id' do
it 'includes all three security headers' do
expect(SafeFetch).to receive(:fetch) do |_received_url, **options, &block|
expect(options[:headers]['X-Chatwoot-Delivery']).to eq('abc-123')
expect(options[:headers]['X-Chatwoot-Timestamp']).to be_present
expect(options[:headers]['X-Chatwoot-Signature']).to start_with('sha256=')
block.call(fetch_result)
expect(RestClient::Request).to receive(:execute) do |args|
expect(args[:headers]['X-Chatwoot-Delivery']).to eq('abc-123')
expect(args[:headers]['X-Chatwoot-Timestamp']).to be_present
expect(args[:headers]['X-Chatwoot-Signature']).to start_with('sha256=')
end
trigger.execute(url, payload, webhook_type, secret: 'mysecret', delivery_id: 'abc-123')
end
end
@@ -260,7 +286,14 @@ describe Webhooks::Trigger do
it 'does not update message status if webhook fails for other events' do
payload = { event: 'conversation_created', conversation: { id: conversation.id }, id: message.id }
expect(SafeFetch).to receive(:fetch).and_raise(SafeFetch::HttpError.new('500 Internal Server Error'))
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect { trigger.execute(url, payload, webhook_type) }.not_to(change { message.reload.status })
end
@@ -269,15 +302,16 @@ describe Webhooks::Trigger do
let(:webhook_timeout) { nil }
it 'falls back to default timeout' do
expect(SafeFetch).to receive(:fetch).with(
url,
method: :post,
body: payload.to_json,
headers: base_headers,
open_timeout: default_timeout,
read_timeout: default_timeout,
validate_content_type: false
).and_yield(fetch_result)
payload = { hello: :hello }
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: default_timeout
).once
trigger.execute(url, payload, webhook_type)
end
@@ -287,15 +321,16 @@ describe Webhooks::Trigger do
let(:webhook_timeout) { -1 }
it 'falls back to default timeout' do
expect(SafeFetch).to receive(:fetch).with(
url,
method: :post,
body: payload.to_json,
headers: base_headers,
open_timeout: default_timeout,
read_timeout: default_timeout,
validate_content_type: false
).and_yield(fetch_result)
payload = { hello: :hello }
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: default_timeout
).once
trigger.execute(url, payload, webhook_type)
end
+6 -6
View File
@@ -9,7 +9,7 @@ describe HookListener do
create(:message, message_type: 'outgoing',
account: account, inbox: inbox, conversation: conversation)
end
let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message, previous_changes: nil) }
let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) }
let(:contact_event) { Events::Base.new('contact.updated', Time.zone.now, contact: conversation.contact) }
let(:conversation_event) { Events::Base.new('conversation.created', Time.zone.now, conversation: conversation) }
@@ -26,7 +26,7 @@ describe HookListener do
context 'when hook is configured' do
it 'triggers hook job' do
hook = create(:integrations_hook, account: account)
expect(HookJob).to receive(:perform_later).with(hook, 'message.created', message: message, previous_changes: nil).once
expect(HookJob).to receive(:perform_later).with(hook, 'message.created', message: message).once
listener.message_created(event)
end
end
@@ -45,7 +45,7 @@ describe HookListener do
context 'when hook is configured' do
it 'triggers hook job' do
hook = create(:integrations_hook, :dialogflow, account: account, inbox: inbox)
expect(HookJob).to receive(:perform_later).with(hook, 'message.updated', message: message, previous_changes: nil).once
expect(HookJob).to receive(:perform_later).with(hook, 'message.updated', message: message).once
listener.message_updated(event)
end
end
@@ -66,21 +66,21 @@ describe HookListener do
context 'when hook is enabled and app_id is supported' do
it 'enqueues the job for slack' do
hook = create(:integrations_hook, account: account)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message, previous_changes: nil)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
listener.message_created(event)
end
it 'enqueues the job for dialogflow' do
hook = create(:integrations_hook, :dialogflow, account: account, inbox: inbox)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message, previous_changes: nil)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
listener.message_created(event)
end
it 'enqueues the job for google_translate' do
hook = create(:integrations_hook, :google_translate, account: account)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message, previous_changes: nil)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
listener.message_created(event)
end
-8
View File
@@ -6,7 +6,6 @@ RSpec.describe AgentBot do
describe 'associations' do
it { is_expected.to have_many(:agent_bot_inboxes) }
it { is_expected.to have_many(:inboxes) }
it { is_expected.to have_many(:platform_app_permissibles) }
end
describe 'concerns' do
@@ -39,13 +38,6 @@ RSpec.describe AgentBot do
expect(message.reload.sender).to be_nil
end
it 'destroys associated platform_app_permissibles' do
platform_app = create(:platform_app)
create(:platform_app_permissible, platform_app: platform_app, permissible: agent_bot)
expect { agent_bot.destroy! }.to change(PlatformAppPermissible, :count).by(-1)
end
end
describe '#system_bot?' do
-25
View File
@@ -879,31 +879,6 @@ RSpec.describe Conversation do
conversation_4.id
]
end
context 'when some conversations have a null waiting_since' do
before do
# rubocop:disable Rails/SkipsModelValidations
conversation_5.update_column(:waiting_since, nil)
conversation_2.update_column(:waiting_since, nil)
# rubocop:enable Rails/SkipsModelValidations
end
it 'places null waiting_since conversations at the end in ascending order' do
records = described_class.sort_on_waiting_since
expect(records.map(&:id)).to eq [
conversation_4.id, conversation_6.id, conversation_7.id, conversation_3.id, conversation_1.id,
conversation_5.id, conversation_2.id
]
end
it 'places null waiting_since conversations at the end in descending order' do
records = described_class.sort_on_waiting_since(:desc)
expect(records.map(&:id)).to eq [
conversation_1.id, conversation_3.id, conversation_7.id, conversation_6.id, conversation_4.id,
conversation_5.id, conversation_2.id
]
end
end
end
end
@@ -205,23 +205,6 @@ RSpec.describe 'MFA API', type: :request do
expect(json_response['error']).to include('Invalid')
end
end
context 'with valid password and backup code' do
it 'disables 2FA successfully' do
backup_code = user.otp_backup_codes.first
delete '/api/v1/profile/mfa',
params: { password: 'Test@123456', backup_code: backup_code },
headers: user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
user.reload
expect(user.otp_required_for_login).to be_falsey
expect(user.otp_secret).to be_nil
expect(user.otp_backup_codes).to be_blank
end
end
end
context 'when 2FA is not enabled' do
-11
View File
@@ -1,11 +0,0 @@
# Playwright Test Configuration
# Base URL for the Chatwoot instance under test
BASE_URL=http://localhost:3000
# Admin credentials
TEST_USER_EMAIL=admin@chatwoot.com
TEST_USER_PASSWORD="Password123@#"
# Add additional variables as needed by specific test suites
# VARIABLE_NAME=value
-54
View File
@@ -1,54 +0,0 @@
# Dependencies
node_modules/
package-lock.json
# Environment files
.env
.env.local
.env.*.local
.github/.env
# Playwright test results
test-results/
playwright-report/
blob-report/
# Playwright cache and auth
playwright/.cache/
playwright/.auth/
.cache/
.auth/
# Test artifacts
screenshots/
videos/
downloads/
traces/
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
.claude
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Generated schemas (if you want to version control them, remove this)
# response-schemas/
# OS files
Thumbs.db
.DS_Store
# Temporary files
*.tmp
*.temp
.tmp/
-146
View File
@@ -1,146 +0,0 @@
# Chatwoot E2E Testing - Documentation
Complete guide for writing and maintaining E2E tests for Chatwoot.
---
## Overview
End-to-end testing suite for Chatwoot built with Playwright and TypeScript using the Component Object Model (COM) pattern.
---
## Architecture
```
tests/playwright/
├── components/
│ ├── api/ # API interaction (auth.component.ts, inbox.component.ts ...)
│ └── ui/ # UI page objects (login.component.ts, agent-page.component.ts ...)
├── tests/e2e/ # Test specs (api/ and ui/)
├── utils/ # Shared utilities (fixture.ts, test-data.ts, db.ts)
├── response-schemas/ # API response schemas for validation
├── fixtures/ # Test fixtures
├── helpers/ # Helper functions
└── playwright.config.ts
```
---
## Configuration
All configuration managed through `.env` file. Copy `.env.example` to `.env`:
```
BASE_URL=http://localhost:3000
TEST_USER_EMAIL=admin@chatwoot.com
TEST_USER_PASSWORD="Password123@#"
ACCOUNT_ID=1
# Add additional variables as needed by specific test suites
# VARIABLE_NAME=value
```
> **Note:** `npx playwright install` is required after `pnpm install` to download browser binaries.
---
## Testing Patterns
### API Testing
```typescript
test('API operation', async ({ api }) => {
const authHeaders = await authComponent.login(email, password);
const result = await component.create(api, authHeaders, data);
expect(result.id).toBeTruthy();
});
```
### UI Testing
```typescript
test('UI interaction', async ({ page }) => {
const loginComponent = new Login(page);
await loginComponent.login(email, password);
await expect(page.getByText('Success')).toBeVisible();
});
```
### Hybrid Pattern
```typescript
test('UI with API setup', async ({ page, api }) => {
// Fast: Create test data via API
const inbox = await inboxComponent.createApiInbox(api, authHeaders, data);
// Test UI interactions
await page.goto(`/app/accounts/2/inbox/${inbox.id}`);
await expect(page.getByText(inbox.name)).toBeVisible();
});
```
---
## Request Handler
```typescript
const data = await api
.path('/api/v1/accounts/2/agents')
.headers(authHeaders)
.body({ name: 'John', email: 'john@test.com' })
.logs(true)
.postRequest(200);
```
**Methods:** `getRequest()`, `postRequest()`, `putRequest()`, `deleteRequest()`
---
## Test Data Generation
```typescript
import { fake } from '@utils/test-data';
const agent = fake.agent({ role: 'agent' });
const inboxName = fake.inboxName();
```
**Available:** `fake.fullName`, `fake.email`, `fake.phoneNumber`, `fake.password`, `fake.agent()`, `fake.inboxName()`
---
## Best Practices
**Do:**
- Use existing components
- Use `fake` for test data
- Use semantic selectors (`getByRole`, `getByLabel`)
- Clean up test data in `afterAll`
- Validate API schemas
**Don't:**
- Use CSS selectors
- Hardcode wait times
- Skip cleanup
- Commit sensitive data
---
## Troubleshooting
**Authentication errors:**
- Verify `.env` credentials match Chatwoot
- Check for rate limiting (429 errors)
**Database errors:**
- Verify database is running
- Check credentials in `.env`
**Timeout errors:**
- Ensure Chatwoot is running at `BASE_URL`
- Increase timeout: `{ timeout: 60000 }`
**Element not found:**
- Use `page.pause()` to inspect
- Check for timing issues
-59
View File
@@ -1,59 +0,0 @@
# Chatwoot E2E Testing
End-to-end testing framework for Chatwoot using Component Object Model pattern.
## Setup
```bash
# Install dependencies
pnpm install
# Install Playwright browsers
npx playwright install
# Configure environment
cp .env.example .env
```
Edit `.env` with your Chatwoot instance URL and credentials.
## Usage
```bash
# Run all tests
pnpm run playwright:run
# Run tests in UI mode
pnpm run playwright:open
# Lint tests and page objects
pnpm run lint
# Generate test code
pnpm run playwright:codegen
```
## Project Structure
```
tests/playwright/
├── components/
│ ├── api/ # API interaction components
│ └── ui/ # UI page objects
├── tests/
│ └── e2e/
│ ├── api/ # Pure API tests
│ └── ui/ # UI tests
├── utils/ # Shared utilities and helpers
├── response-schemas/ # API response schemas for validation
├── fixtures/ # Test fixtures
└── helpers/ # Helper functions
```
## Documentation
See [DOCS.md](./DOCS.md) for complete testing guide including patterns, conventions, and troubleshooting.
## Configuration
See `.env.example` for the full list of variables.
-1
View File
@@ -1 +0,0 @@
export { Login } from './login.component';
@@ -1,55 +0,0 @@
import { Page } from '@playwright/test';
export class Login {
private page: Page;
constructor(page: Page) {
this.page = page;
}
async navigate() {
await this.page.goto('/');
}
async fillEmail(email: string) {
await this.page.getByTestId('email_input').fill(email);
}
async fillPassword(password: string) {
await this.page.getByTestId('password_input').fill(password);
}
async clickLoginButton() {
await this.page.getByTestId('submit_button').click();
}
async login(email: string, password: string) {
await this.fillEmail(email);
await this.fillPassword(password);
await this.clickLoginButton();
}
getEmailInput() {
return this.page.getByTestId('email_input');
}
getPasswordInput() {
return this.page.getByTestId('password_input');
}
getLoginButton() {
return this.page.getByTestId('submit_button');
}
getLoginHeading() {
return this.page.getByRole('heading', { name: 'Login to Chatwoot' });
}
getSSOLink() {
return this.page.getByRole('link', { name: 'Login via SSO' });
}
getForgotPasswordLink() {
return this.page.getByRole('link', { name: 'Forgot your password?' });
}
}
-42
View File
@@ -1,42 +0,0 @@
import js from '@eslint/js';
import playwright from 'eslint-plugin-playwright';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: [
'node_modules/',
'dist/',
'test-results/',
'playwright-report/',
'blob-report/',
'.cache/',
'.auth/',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{js,mjs,ts}'],
languageOptions: {
globals: {
__dirname: 'readonly',
console: 'readonly',
process: 'readonly',
},
},
},
{
files: ['**/*.ts'],
languageOptions: {
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
},
},
{
...playwright.configs['flat/recommended'],
files: ['tests/**/*.ts'],
}
);
-35
View File
@@ -1,35 +0,0 @@
{
"name": "playwright",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"playwright:open": "npx playwright test --ui",
"playwright:run": "npx playwright test",
"playwright:codegen": "npx playwright codegen"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@eslint/js": "^9.39.4",
"@faker-js/faker": "^9.9.0",
"@playwright/mcp": "^0.0.48",
"@playwright/test": "^1.56.1",
"@types/node": "^24.10.1",
"@types/uuid": "^10.0.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"dotenv": "^17.2.3",
"eslint": "^9.39.4",
"eslint-plugin-playwright": "^2.10.2",
"genson-js": "^0.0.8",
"pg": "^8.16.3",
"playwright": "^1.56.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.1",
"uuid": "^13.0.0"
}
}
-36
View File
@@ -1,36 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({ path: path.resolve(__dirname, '.env') });
export default defineConfig({
testDir: './tests',
timeout: 60 * 1000,
expect: {
timeout: 30 * 1000,
},
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
actionTimeout: 30 * 1000,
navigationTimeout: 30 * 1000,
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'retain-on-failure',
headless: false,
viewport: {
width: 1440,
height: 1080,
},
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
-1247
View File
File diff suppressed because it is too large Load Diff
@@ -1,65 +0,0 @@
import { test, expect } from '@playwright/test';
import { Login } from '@components/ui';
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'admin@chatwoot.com';
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Password123@#';
const INVALID_PASSWORD = 'Password';
test.describe('Login page', () => {
let loginComponent: Login;
test.beforeEach(async ({ page }) => {
loginComponent = new Login(page);
await loginComponent.navigate();
});
test('should allow user to login with valid credentials', async ({
page,
}) => {
await loginComponent.login(TEST_EMAIL, TEST_PASSWORD);
await expect(page).toHaveURL(/\/app\/accounts\/\d+\/dashboard/);
});
test('renders all critical components', async ({ page }) => {
await expect(page).toHaveTitle('Chatwoot');
await expect(loginComponent.getLoginHeading()).toBeVisible();
const emailInput = loginComponent.getEmailInput();
await expect(emailInput).toBeVisible();
await expect(emailInput).toHaveAttribute('name', 'email_address');
await expect(page.getByText('Email')).toBeVisible();
const passwordInput = loginComponent.getPasswordInput();
await expect(passwordInput).toBeVisible();
await expect(passwordInput).toHaveAttribute('type', 'password');
await expect(page.getByText('PasswordForgot your password?')).toBeVisible();
const togglePasswordButton = page.getByRole('button', {
name: 'Show password',
});
await expect(togglePasswordButton).toBeVisible();
await expect(togglePasswordButton).toHaveAttribute('type', 'button');
await expect(togglePasswordButton.locator('.i-lucide-eye')).toBeVisible();
const forgotPasswordLink = loginComponent.getForgotPasswordLink();
await expect(forgotPasswordLink).toBeVisible();
await expect(forgotPasswordLink).toHaveAttribute(
'href',
'/app/auth/reset/password'
);
const loginButton = loginComponent.getLoginButton();
await expect(loginButton).toBeVisible();
await expect(loginButton).toHaveAttribute('type', 'submit');
});
test('should show error for invalid password', async ({ page }) => {
await loginComponent.login(TEST_EMAIL, INVALID_PASSWORD);
await expect(
page.getByText('Invalid login credentials. Please try again.')
).toBeVisible();
await expect(page).toHaveURL(/\/app\/login/);
});
});
-25
View File
@@ -1,25 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@components/*": ["components/*"],
"@utils/*": ["utils/*"],
"@fixtures/*": ["fixtures/*"],
"@helpers/*": ["helpers/*"],
"@tests/*": ["tests/*"]
},
"types": ["node", "@playwright/test"]
},
"include": ["**/*.ts"],
"exclude": ["node_modules"]
}
-5
View File
@@ -418,10 +418,5 @@ export const icons = {
</g>
</g>`,
},
'onboarding-greeting': {
body: `<path d="M8.00008 14.0007C8.73646 14.0007 9.33341 13.4037 9.33341 12.6673C9.33341 11.9309 8.73646 11.334 8.00008 11.334C7.2637 11.334 6.66675 11.9309 6.66675 12.6673C6.66675 13.4037 7.2637 14.0007 8.00008 14.0007Z" fill="none" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.00008 4.66667C8.73646 4.66667 9.33341 4.06971 9.33341 3.33333C9.33341 2.59695 8.73646 2 8.00008 2C7.2637 2 6.66675 2.59695 6.66675 3.33333C6.66675 4.06971 7.2637 4.66667 8.00008 4.66667Z" fill="none" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/><path d="M10.6666 9.33268C11.403 9.33268 11.9999 8.73573 11.9999 7.99935C11.9999 7.26297 11.403 6.66602 10.6666 6.66602C9.93021 6.66602 9.33325 7.26297 9.33325 7.99935C9.33325 8.73573 9.93021 9.33268 10.6666 9.33268Z" fill="none" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/><path d="M13.3333 14.0007C14.0697 14.0007 14.6667 13.4037 14.6667 12.6673C14.6667 11.9309 14.0697 11.334 13.3333 11.334C12.597 11.334 12 11.9309 12 12.6673C12 13.4037 12.597 14.0007 13.3333 14.0007Z" fill="none" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.66659 14.0007C3.40297 14.0007 3.99992 13.4037 3.99992 12.6673C3.99992 11.9309 3.40297 11.334 2.66659 11.334C1.93021 11.334 1.33325 11.9309 1.33325 12.6673C1.33325 13.4037 1.93021 14.0007 2.66659 14.0007Z" fill="none" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.33333 9.33268C6.06971 9.33268 6.66667 8.73573 6.66667 7.99935C6.66667 7.26297 6.06971 6.66602 5.33333 6.66602C4.59695 6.66602 4 7.26297 4 7.99935C4 8.73573 4.59695 9.33268 5.33333 9.33268Z" fill="none" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>`,
width: 16,
height: 16,
},
/** Ends */
};