Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3dbf44d57 | ||
|
|
e2c58a1769 | ||
|
|
3f72bbf286 | ||
|
|
b1fc0c20fc | ||
|
|
3c34830bd8 | ||
|
|
eecb4a999d |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
+1
-32
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,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?
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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 +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?' });
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
Generated
-1247
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/);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 */
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user