Merge branch 'develop' into chore/upgrade-page

This commit is contained in:
Sivin Varghese
2025-04-07 10:24:42 +05:30
committed by GitHub
108 changed files with 3060 additions and 556 deletions
@@ -0,0 +1,30 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
include InstagramConcern
include Instagram::IntegrationHelper
before_action :check_authorization
def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
redirect_url = instagram_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/instagram/callback",
scope: REQUIRED_SCOPES.join(','),
enable_fb_login: '0',
force_authentication: '1',
response_type: 'code',
state: generate_instagram_token(Current.account.id)
}
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -9,11 +9,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portals = Current.account.portals
end
def add_members
agents = Current.account.agents.where(id: portal_member_params[:member_ids])
@portal.members << agents
end
def show
@all_articles = @portal.articles
@articles = @all_articles.search(locale: params[:locale])
@@ -85,10 +80,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
{ channel_web_widget_id: inbox.channel.id }
end
def portal_member_params
params.require(:portal).permit(:account_id, member_ids: [])
end
def set_current_page
@current_page = params[:page] || 1
end
@@ -66,9 +66,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end
def check_authorization
return if Current.account_user.administrator?
raise Pundit::NotAuthorizedError
authorize :report, :view?
end
def common_params
@@ -137,5 +135,3 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
end
Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')
@@ -0,0 +1,75 @@
module InstagramConcern
extend ActiveSupport::Concern
include HTTParty
def instagram_client
::OAuth2::Client.new(
client_id,
client_secret,
{
site: 'https://api.instagram.com',
authorize_url: 'https://api.instagram.com/oauth/authorize',
token_url: 'https://api.instagram.com/oauth/access_token',
auth_scheme: :request_body,
token_method: :post
}
)
end
private
def client_id
GlobalConfigService.load('INSTAGRAM_APP_ID', nil)
end
def client_secret
GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil)
end
def exchange_for_long_lived_token(short_lived_token)
endpoint = 'https://graph.instagram.com/access_token'
params = {
grant_type: 'ig_exchange_token',
client_secret: client_secret,
access_token: short_lived_token,
client_id: client_id
}
make_api_request(endpoint, params, 'Failed to exchange token')
end
def fetch_instagram_user_details(access_token)
endpoint = 'https://graph.instagram.com/v22.0/me'
params = {
fields: 'id,username,user_id,name,profile_picture_url,account_type',
access_token: access_token
}
make_api_request(endpoint, params, 'Failed to fetch Instagram user details')
end
def make_api_request(endpoint, params, error_prefix)
response = HTTParty.get(
endpoint,
query: params,
headers: { 'Accept' => 'application/json' }
)
unless response.success?
Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}"
raise "#{error_prefix}: #{response.body}"
end
begin
JSON.parse(response.body)
rescue JSON::ParserError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Invalid JSON response: #{response.body}"
raise e
end
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -0,0 +1,163 @@
class Instagram::CallbacksController < ApplicationController
include InstagramConcern
include Instagram::IntegrationHelper
def show
# Check if Instagram redirected with an error (user canceled authorization)
# See: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization
if params[:error].present?
handle_authorization_error
return
end
process_successful_authorization
rescue StandardError => e
handle_error(e)
end
private
# Process the authorization code and create inbox
def process_successful_authorization
@response = instagram_client.auth_code.get_token(
oauth_code,
redirect_uri: "#{base_url}/#{provider_name}/callback",
grant_type: 'authorization_code'
)
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
inbox, already_exists = find_or_create_inbox
if already_exists
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
end
end
# Handle all errors that might occur during authorization
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#sample-rejected-response
def handle_error(error)
Rails.logger.error("Instagram Channel creation Error: #{error.message}")
ChatwootExceptionTracker.new(error).capture_exception
error_info = extract_error_info(error)
redirect_to_error_page(error_info)
end
# Extract error details from the exception
def extract_error_info(error)
if error.is_a?(OAuth2::Error)
begin
# Instagram returns JSON error response which we parse to extract error details
JSON.parse(error.message)
rescue JSON::ParseError
# Fall back to a generic OAuth error if JSON parsing fails
{ 'error_type' => 'OAuthException', 'code' => 400, 'error_message' => error.message }
end
else
# For other unexpected errors
{ 'error_type' => error.class.name, 'code' => 500, 'error_message' => error.message }
end
end
# Handles the case when a user denies permissions or cancels the authorization flow
# Error parameters are documented at:
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization
def handle_authorization_error
error_info = {
'error_type' => params[:error] || 'authorization_error',
'code' => 400,
'error_message' => params[:error_description] || 'Authorization was denied'
}
Rails.logger.error("Instagram Authorization Error: #{error_info['error_message']}")
redirect_to_error_page(error_info)
end
# Centralized method to redirect to error page with appropriate parameters
# This ensures consistent error handling across different error scenarios
# Frontend will handle the error page based on the error_type
def redirect_to_error_page(error_info)
redirect_to app_new_instagram_inbox_url(
account_id: account_id,
error_type: error_info['error_type'],
code: error_info['code'],
error_message: error_info['error_message']
)
end
def find_or_create_inbox
user_details = fetch_instagram_user_details(@long_lived_token_response['access_token'])
channel_instagram = find_channel_by_instagram_id(user_details['user_id'].to_s)
channel_exists = channel_instagram.present?
if channel_instagram
update_channel(channel_instagram, user_details)
else
channel_instagram = create_channel_with_inbox(user_details)
end
# reauthorize channel, this code path only triggers when instagram auth is successful
# reauthorized will also update cache keys for the associated inbox
channel_instagram.reauthorized!
[channel_instagram.inbox, channel_exists]
end
def find_channel_by_instagram_id(instagram_id)
Channel::Instagram.find_by(instagram_id: instagram_id, account: account)
end
def update_channel(channel_instagram, user_details)
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
channel_instagram.update!(
access_token: @long_lived_token_response['access_token'],
expires_at: expires_at
)
# Update inbox name if username changed
channel_instagram.inbox.update!(name: user_details['username'])
channel_instagram
end
def create_channel_with_inbox(user_details)
ActiveRecord::Base.transaction do
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
channel_instagram = Channel::Instagram.create!(
access_token: @long_lived_token_response['access_token'],
instagram_id: user_details['user_id'].to_s,
account: account,
expires_at: expires_at
)
account.inboxes.create!(
account: account,
channel: channel_instagram,
name: user_details['username']
)
channel_instagram
end
end
def account_id
return unless params[:state]
verify_instagram_token(params[:state])
end
def oauth_code
params[:code]
end
def account
@account ||= Account.find(account_id)
end
def provider_name
'instagram'
end
end
@@ -43,6 +43,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
['MAILER_INBOUND_EMAIL_DOMAIN']
when 'linear'
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
when 'instagram'
%w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
else
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end
@@ -15,6 +15,9 @@ class Webhooks::InstagramController < ActionController::API
private
def valid_token?(token)
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
# Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and
# INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login)
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') ||
token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '')
end
end
+2 -1
View File
@@ -81,7 +81,8 @@ class AccountDashboard < Administrate::BaseDashboard
COLLECTION_FILTERS = {
active: ->(resources) { resources.where(status: :active) },
suspended: ->(resources) { resources.where(status: :suspended) },
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) },
marked_for_deletion: ->(resources) { resources.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL") }
}.freeze
# Overwrite this method to customize how accounts are displayed
@@ -0,0 +1,49 @@
module Instagram::IntegrationHelper
REQUIRED_SCOPES = %w[instagram_business_basic instagram_business_manage_messages].freeze
# Generates a signed JWT token for Instagram integration
#
# @param account_id [Integer] The account ID to encode in the token
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_instagram_token(account_id)
return if client_secret.blank?
JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Instagram token: #{e.message}")
nil
end
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
# Verifies and decodes a Instagram JWT token
#
# @param token [String] The JWT token to verify
# @return [Integer, nil] The account ID from the token or nil if invalid
def verify_instagram_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)
end
private
def client_secret
@client_secret ||= GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil)
end
def decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
nil
end
end
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from '../ApiClient';
class InstagramChannel extends ApiClient {
constructor() {
super('instagram', { accountScoped: true });
}
generateAuthorization(payload) {
return axios.post(`${this.url}/authorization`, payload);
}
}
export default new InstagramChannel();
@@ -17,6 +17,12 @@ class EnterpriseAccountAPI extends ApiClient {
getLimits() {
return axios.get(`${this.url}limits`);
}
toggleDeletion(action) {
return axios.post(`${this.url}toggle_deletion`, {
action_type: action,
});
}
}
export default new EnterpriseAccountAPI();
@@ -10,6 +10,7 @@ describe('#enterpriseAccountAPI', () => {
expect(accountAPI).toHaveProperty('update');
expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout');
expect(accountAPI).toHaveProperty('toggleDeletion');
});
describe('API calls', () => {
@@ -42,5 +43,21 @@ describe('#enterpriseAccountAPI', () => {
'/enterprise/api/v1/subscription'
);
});
it('#toggleDeletion with delete action', () => {
accountAPI.toggleDeletion('delete');
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/toggle_deletion',
{ action_type: 'delete' }
);
});
it('#toggleDeletion with undelete action', () => {
accountAPI.toggleDeletion('undelete');
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/toggle_deletion',
{ action_type: 'undelete' }
);
});
});
});
@@ -10,6 +10,7 @@ import {
ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
ALLOWED_FILE_TYPES_FOR_LINE,
ALLOWED_FILE_TYPES_FOR_INSTAGRAM,
} from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
@@ -113,6 +114,10 @@ export default {
type: String,
required: true,
},
conversationType: {
type: String,
default: '',
},
},
emits: [
'replaceText',
@@ -187,6 +192,9 @@ export default {
showAudioPlayStopButton() {
return this.showAudioRecorder && this.isRecordingAudio;
},
isInstagramDM() {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() {
if (this.isATwilioWhatsAppChannel) {
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
@@ -194,6 +202,10 @@ export default {
if (this.isALineChannel) {
return ALLOWED_FILE_TYPES_FOR_LINE;
}
if (this.isAInstagramChannel || this.isInstagramDM) {
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
}
return ALLOWED_FILE_TYPES;
},
enableDragAndDrop() {
@@ -226,16 +226,23 @@ export default {
return this.$t('CONVERSATION.CANNOT_REPLY');
},
replyWindowLink() {
if (this.isAWhatsAppChannel) {
if (this.isAFacebookInbox || this.isAInstagramChannel) {
return REPLY_POLICY.FACEBOOK;
}
if (this.isAWhatsAppCloudChannel) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (!this.isAPIInbox) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
return '';
},
replyWindowLinkText() {
if (this.isAWhatsAppChannel) {
if (
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAInstagramChannel
) {
return this.$t('CONVERSATION.24_HOURS_WINDOW');
}
if (!this.isAPIInbox) {
@@ -485,7 +492,7 @@ export default {
<Banner
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mt-2 mx-2 rounded-lg overflow-hidden"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
@@ -1195,6 +1195,7 @@ export default {
:mode="replyType"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
+6
View File
@@ -9,6 +9,7 @@ export const INBOX_TYPES = {
TELEGRAM: 'Channel::Telegram',
LINE: 'Channel::Line',
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
};
const INBOX_ICON_MAP_FILL = {
@@ -20,6 +21,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.EMAIL]: 'i-ri-mail-fill',
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -33,6 +35,7 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.EMAIL]: 'i-ri-mail-line',
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
[INBOX_TYPES.LINE]: 'i-ri-line-line',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
@@ -118,6 +121,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'brand-line';
case INBOX_TYPES.INSTAGRAM:
return 'brand-instagram';
default:
return 'chat';
}
@@ -14,6 +14,26 @@
"ERROR": "Could not update settings, try again!",
"SUCCESS": "Successfully updated account settings"
},
"ACCOUNT_DELETE_SECTION": {
"TITLE": "Delete your Account",
"NOTE": "Once you delete your account, all your data will be deleted.",
"BUTTON_TEXT": "Delete Your Account",
"CONFIRM": {
"TITLE": "Delete Account",
"MESSAGE": "Deleting your Account is irreversible. Enter your account name below to confirm you want to permanently delete it.",
"BUTTON_TEXT": "Delete",
"DISMISS": "Cancel",
"PLACE_HOLDER": "Please type {accountName} to confirm"
},
"SUCCESS": "Account marked for deletion",
"FAILURE": "Could not delete account, try again!",
"SCHEDULED_DELETION": {
"TITLE": "Account Scheduled for Deletion",
"MESSAGE_MANUAL": "This account is scheduled for deletion on {deletionDate}. This was requested by an administrator. You can cancel the deletion before this date.",
"MESSAGE_INACTIVITY": "This account is scheduled for deletion on {deletionDate} due to account inactivity. You can cancel the deletion before this date.",
"CLEAR_BUTTON": "Cancel Scheduled Deletion"
}
},
"FORM": {
"ERROR": "Please fix form errors",
"GENERAL_SECTION": {
@@ -43,7 +43,14 @@
"INBOX_NAME": "Inbox Name",
"ADD_NAME": "Add a name for your inbox",
"PICK_NAME": "Pick a Name for your Inbox",
"PICK_A_VALUE": "Pick a value"
"PICK_A_VALUE": "Pick a value",
"CREATE_INBOX": "Create Inbox"
},
"INSTAGRAM": {
"CONTINUE_WITH_INSTAGRAM": "Continue with Instagram",
"HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ",
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again"
},
"TWITTER": {
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
@@ -753,7 +760,8 @@
"EMAIL": "Email",
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel"
"API": "API Channel",
"INSTAGRAM": "Instagram"
}
}
}
@@ -11,11 +11,15 @@ import semver from 'semver';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import V4Button from 'dashboard/components-next/button/Button.vue';
import WootConfirmDeleteModal from 'dashboard/components/widgets/modal/ConfirmDeleteModal.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
BaseSettingsHeader,
V4Button,
WootConfirmDeleteModal,
NextButton,
},
setup() {
const { updateUISettings } = useUISettings();
@@ -35,6 +39,7 @@ export default {
features: {},
autoResolveDuration: null,
latestChatwootVersion: null,
showDeletePopup: false,
};
},
validations: {
@@ -55,6 +60,7 @@ export default {
getAccount: 'accounts/getAccount',
uiFlags: 'accounts/getUIFlags',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
showAutoResolutionConfig() {
return this.isFeatureEnabledonAccount(
@@ -101,6 +107,34 @@ export default {
getAccountId() {
return this.id.toString();
},
confirmPlaceHolderText() {
return `${this.$t(
'GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.PLACE_HOLDER',
{
accountName: this.name,
}
)}`;
},
isMarkedForDeletion() {
const { custom_attributes = {} } = this.currentAccount;
return !!custom_attributes.marked_for_deletion_at;
},
markedForDeletionDate() {
const { custom_attributes = {} } = this.currentAccount;
if (!custom_attributes.marked_for_deletion_at) return null;
return new Date(custom_attributes.marked_for_deletion_at);
},
markedForDeletionReason() {
const { custom_attributes = {} } = this.currentAccount;
return custom_attributes.marked_for_deletion_reason || 'manual_deletion';
},
formattedDeletionDate() {
if (!this.markedForDeletionDate) return '';
return this.markedForDeletionDate.toLocaleString();
},
currentAccount() {
return this.getAccount(this.accountId) || {};
},
},
mounted() {
this.initializeAccount();
@@ -162,6 +196,56 @@ export default {
rtl_view: isRTLSupported,
});
},
// Delete Function
openDeletePopup() {
this.showDeletePopup = true;
},
closeDeletePopup() {
this.showDeletePopup = false;
},
async markAccountForDeletion() {
this.closeDeletePopup();
try {
// Use the enterprise API to toggle deletion with delete action
await this.$store.dispatch('accounts/toggleDeletion', {
action_type: 'delete',
});
// Refresh account data
await this.$store.dispatch('accounts/get');
useAlert(this.$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SUCCESS'));
} catch (error) {
// Handle error message
this.handleDeletionError(error);
}
},
handleDeletionError(error) {
const errorKey = error.response?.data?.error_key;
if (errorKey) {
useAlert(
this.$t(`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.${errorKey}`)
);
return;
}
const message = error.response?.data?.message;
if (message) {
useAlert(message);
return;
}
useAlert(this.$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.FAILURE'));
},
async clearDeletionMark() {
try {
// Use the enterprise API to toggle deletion with undelete action
await this.$store.dispatch('accounts/toggleDeletion', {
action_type: 'undelete',
});
// Refresh account data
await this.$store.dispatch('accounts/get');
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
} catch (error) {
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR'));
}
},
},
};
</script>
@@ -175,7 +259,7 @@ export default {
</V4Button>
</template>
</BaseSettingsHeader>
<div class="flex-grow flex-shrink min-w-0 overflow-auto mt-3">
<div class="flex-grow flex-shrink min-w-0 mt-3 overflow-auto">
<form v-if="!uiFlags.isFetchingItem" @submit.prevent="updateAccount">
<div
class="flex flex-row border-b border-slate-25 dark:border-slate-800"
@@ -279,6 +363,73 @@ export default {
<woot-code :script="getAccountId" />
</div>
</div>
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<div
class="flex flex-row pt-4 mt-2 border-t border-slate-25 dark:border-slate-800 text-black-900 dark:text-slate-300"
>
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.TITLE') }}
</h4>
<p>
{{ $t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.NOTE') }}
</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<div v-if="isMarkedForDeletion">
<div
class="p-4 flex-grow-0 flex-shrink-0 flex-[50%] bg-red-50 dark:bg-red-900 rounded"
>
<p class="mb-4">
{{
$t(
`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_${markedForDeletionReason === 'manual_deletion' ? 'MANUAL' : 'INACTIVITY'}`,
{
deletionDate: formattedDeletionDate,
}
)
}}
</p>
<NextButton
:label="
$t(
'GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.CLEAR_BUTTON'
)
"
color="ruby"
:is-loading="uiFlags.isUpdating"
@click="clearDeletionMark"
/>
</div>
</div>
<div v-if="!isMarkedForDeletion">
<NextButton
:label="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.BUTTON_TEXT')"
color="ruby"
@click="openDeletePopup()"
/>
</div>
</div>
</div>
<WootConfirmDeleteModal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.TITLE')"
:message="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.MESSAGE')"
:confirm-text="
$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.BUTTON_TEXT')
"
:reject-text="
$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.DISMISS')
"
:confirm-value="name"
:confirm-place-holder-text="confirmPlaceHolderText"
@on-confirm="markAccountForDeletion"
@on-close="closeDeletePopup"
/>
</div>
<div class="p-4 text-sm text-center">
<div>{{ `v${globalConfig.appVersion}` }}</div>
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
@@ -9,6 +9,7 @@ import Sms from './channels/Sms.vue';
import Whatsapp from './channels/Whatsapp.vue';
import Line from './channels/Line.vue';
import Telegram from './channels/Telegram.vue';
import Instagram from './channels/Instagram.vue';
const channelViewList = {
facebook: Facebook,
@@ -20,6 +21,7 @@ const channelViewList = {
whatsapp: Whatsapp,
line: Line,
telegram: Telegram,
instagram: Instagram,
};
export default defineComponent({
@@ -7,6 +7,7 @@ import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.
import SettingsSection from '../../../../components/SettingsSection.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import FacebookReauthorize from './facebook/Reauthorize.vue';
import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import GoogleReauthorize from './channels/google/Reauthorize.vue';
import PreChatFormSettings from './PreChatForm/Settings.vue';
@@ -36,6 +37,7 @@ export default {
MicrosoftReauthorize,
GoogleReauthorize,
NextButton,
InstagramReauthorize,
},
mixins: [inboxMixin],
setup() {
@@ -202,6 +204,9 @@ export default {
return true;
return false;
},
instagramUnauthorized() {
return this.isAInstagramChannel && this.inbox.reauthorization_required;
},
microsoftUnauthorized() {
return this.isAMicrosoftInbox && this.inbox.reauthorization_required;
},
@@ -383,10 +388,11 @@ export default {
/>
</woot-tabs>
</SettingIntroBanner>
<section class="max-w-6xl mx-auto w-full">
<section class="w-full max-w-6xl mx-auto">
<MicrosoftReauthorize v-if="microsoftUnauthorized" :inbox="inbox" />
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
<div v-if="selectedTabKey === 'inbox_settings'" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')"
@@ -11,6 +11,7 @@ import ChannelApi from '../../../../../api/channels';
import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import * as Sentry from '@sentry/vue';
@@ -19,6 +20,7 @@ export default {
components: {
LoadingState,
PageHeader,
NextButton,
},
mixins: [globalConfigMixin],
setup() {
@@ -207,7 +209,7 @@ export default {
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
>
<div
v-if="!hasLoginStarted"
@@ -240,7 +242,7 @@ export default {
<LoadingState v-else-if="showLoader" :message="emptyStateMessage" />
<form
v-else
class="flex flex-wrap flex-col mx-0"
class="flex flex-col flex-wrap mx-0"
@submit.prevent="createChannel()"
>
<div class="w-full">
@@ -291,7 +293,7 @@ export default {
</label>
</div>
<div class="w-full text-right">
<input type="submit" value="Create Inbox" class="button" />
<NextButton :label="$t('INBOX_MGMT.ADD.FB.CREATE_INBOX')" />
</div>
</div>
</form>
@@ -0,0 +1,129 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { useAccount } from 'dashboard/composables/useAccount';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import instagramClient from 'dashboard/api/channel/instagramClient';
export default {
mixins: [globalConfigMixin],
setup() {
const { accountId } = useAccount();
return {
accountId,
v$: useVuelidate(),
};
},
data() {
return {
isCreating: false,
hasError: false,
errorStateMessage: '',
errorStateDescription: '',
isRequestingAuthorization: false,
};
},
mounted() {
const urlParams = new URLSearchParams(window.location.search);
// TODO: Handle error type
// const errorType = urlParams.get('error_type');
const errorCode = urlParams.get('code');
const errorMessage = urlParams.get('error_message');
if (errorMessage) {
this.hasError = true;
if (errorCode === '400') {
this.errorStateMessage = errorMessage;
this.errorStateDescription = this.$t(
'INBOX_MGMT.ADD.INSTAGRAM.ERROR_AUTH'
);
} else {
this.errorStateMessage = this.$t(
'INBOX_MGMT.ADD.INSTAGRAM.ERROR_MESSAGE'
);
this.errorStateDescription = errorMessage;
}
}
// User need to remove the error params from the url to avoid the error to be shown again after page reload, so that user can try again
const cleanURL = window.location.pathname;
window.history.replaceState({}, document.title, cleanURL);
},
methods: {
async requestAuthorization() {
this.isRequestingAuthorization = true;
const response = await instagramClient.generateAuthorization();
const {
data: { url },
} = response;
window.location.href = url;
},
},
};
</script>
<template>
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<div class="flex flex-col items-center justify-center h-full text-center">
<div v-if="hasError" class="max-w-lg mx-auto text-center">
<h5>{{ errorStateMessage }}</h5>
<p
v-if="errorStateDescription"
v-dompurify-html="errorStateDescription"
/>
</div>
<div
v-else
class="flex flex-col items-center justify-center h-full text-center"
>
<button
class="flex items-center justify-center px-8 py-3.5 text-white rounded-full bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45] hover:shadow-lg transition-all duration-300 min-w-[240px] overflow-hidden"
:disabled="isRequestingAuthorization"
@click="requestAuthorization()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"
/>
</svg>
<span class="text-base font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM') }}
</span>
<span v-if="isRequestingAuthorization" class="ml-2">
<svg
class="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</span>
</button>
<p class="py-6">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,37 @@
<script setup>
import { ref } from 'vue';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import instagramClient from 'dashboard/api/channel/instagramClient';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
const { t } = useI18n();
const isRequestingAuthorization = ref(false);
async function requestAuthorization() {
try {
isRequestingAuthorization.value = true;
const response = await instagramClient.generateAuthorization();
const {
data: { url },
} = response;
window.location.href = url;
} catch (error) {
useAlert(t('INBOX_MGMT.ADD.INSTAGRAM.ERROR_AUTH'));
} finally {
isRequestingAuthorization.value = false;
}
}
</script>
<template>
<InboxReconnectionRequired
class="mx-8 mt-5"
@reauthorize="requestAuthorization"
/>
</template>
@@ -28,6 +28,7 @@ const i18nMap = {
'Channel::Telegram': 'TELEGRAM',
'Channel::Line': 'LINE',
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
};
const twilioChannelName = () => {
@@ -74,6 +74,29 @@ export const actions = {
throw new Error(error);
}
},
delete: async ({ commit }, { id }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
try {
await AccountAPI.delete(id);
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
throw new Error(error);
}
},
toggleDeletion: async (
{ commit },
{ action_type } = { action_type: 'delete' }
) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
try {
await EnterpriseAccountAPI.toggleDeletion(action_type);
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
throw new Error(error);
}
},
create: async ({ commit }, accountInfo) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCreating: true });
try {
@@ -80,4 +80,41 @@ describe('#actions', () => {
]);
});
});
describe('#toggleDeletion', () => {
it('sends correct actions with delete action if API is success', async () => {
axios.post.mockResolvedValue({});
await actions.toggleDeletion({ commit }, { action_type: 'delete' });
expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }],
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }],
]);
expect(axios.post.mock.calls[0][1]).toEqual({
action_type: 'delete',
});
});
it('sends correct actions with undelete action if API is success', async () => {
axios.post.mockResolvedValue({});
await actions.toggleDeletion({ commit }, { action_type: 'undelete' });
expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }],
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }],
]);
expect(axios.post.mock.calls[0][1]).toEqual({
action_type: 'undelete',
});
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.toggleDeletion({ commit }, { action_type: 'delete' })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }],
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }],
]);
});
});
});
+2
View File
@@ -3,4 +3,6 @@ export const REPLY_POLICY = {
'https://developers.facebook.com/docs/messenger-platform/policy/policy-overview/',
TWILIO_WHATSAPP:
'https://www.twilio.com/docs/whatsapp/tutorial/send-whatsapp-notification-messages-templates#sending-non-template-messages-within-a-24-hour-session',
WHATSAPP_CLOUD:
'https://business.whatsapp.com/policy#:~:text=You%20may%20reply%20to%20a,messages%20via%20approved%20Message%20Templates.',
};
@@ -57,6 +57,10 @@ export const ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP =
// https://developers.line.biz/en/reference/messaging-api/#image-message, https://developers.line.biz/en/reference/messaging-api/#video-message
export const ALLOWED_FILE_TYPES_FOR_LINE = 'image/png, image/jpeg,video/mp4';
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#requirements
export const ALLOWED_FILE_TYPES_FOR_INSTAGRAM =
'image/png, image/jpeg, video/mp4, video/mov, video/webm';
export const CSAT_RATINGS = [
{
key: 'disappointed',
@@ -121,6 +121,9 @@ export default {
this.isATwilioWhatsAppChannel
);
},
isAInstagramChannel() {
return this.channelType === INBOX_TYPES.INSTAGRAM;
},
},
methods: {
inboxHasFeature(feature) {
+1 -1
View File
@@ -51,7 +51,7 @@ class Account::ContactsExportJob < ApplicationJob
def send_mail
file_url = account_contact_export_url
mailer = AdministratorNotifications::ChannelNotificationsMailer.with(account: @account)
mailer = AdministratorNotifications::AccountNotificationMailer.with(account: @account)
mailer.contact_export_complete(file_url, @account_user.email)&.deliver_later
end
+2 -2
View File
@@ -93,10 +93,10 @@ class DataImportJob < ApplicationJob
end
def send_import_notification_to_admin
AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later
end
def send_import_failed_notification_to_admin
AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_failed.deliver_later
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later
end
end
@@ -0,0 +1,48 @@
class AdministratorNotifications::AccountNotificationMailer < AdministratorNotifications::BaseMailer
def account_deletion(account, reason = 'manual_deletion')
subject = 'Your account has been marked for deletion'
action_url = settings_url('general')
meta = {
'account_name' => account.name,
'deletion_date' => account.custom_attributes['marked_for_deletion_at'],
'reason' => reason
}
send_notification(subject, action_url: action_url, meta: meta)
end
def contact_import_complete(resource)
subject = 'Contact Import Completed'
action_url = if resource.failed_records.attached?
Rails.application.routes.url_helpers.rails_blob_url(resource.failed_records)
else
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{resource.account.id}/contacts"
end
meta = {
'failed_contacts' => resource.total_records - resource.processed_records,
'imported_contacts' => resource.processed_records
}
send_notification(subject, action_url: action_url, meta: meta)
end
def contact_import_failed
subject = 'Contact Import Failed'
send_notification(subject)
end
def contact_export_complete(file_url, email_to)
subject = "Your contact's export file is available to download."
send_notification(subject, to: email_to, action_url: file_url)
end
def automation_rule_disabled(rule)
subject = 'Automation rule disabled due to validation errors.'
action_url = settings_url('automation/list')
meta = { 'rule_name' => rule.name }
send_notification(subject, action_url: action_url, meta: meta)
end
end
@@ -0,0 +1,31 @@
class AdministratorNotifications::BaseMailer < ApplicationMailer
# Common method to check SMTP configuration and send mail with liquid
def send_notification(subject, to: nil, action_url: nil, meta: {})
return unless smtp_config_set_or_development?
@action_url = action_url
@meta = meta || {}
send_mail_with_liquid(to: to || admin_emails, subject: subject) and return
end
# Helper method to generate inbox URL
def inbox_url(inbox)
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/inboxes/#{inbox.id}"
end
# Helper method to generate settings URL
def settings_url(section)
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/#{section}"
end
private
def admin_emails
Current.account.administrators.pluck(:email)
end
def liquid_locals
super.merge({ meta: @meta })
end
end
@@ -1,93 +1,21 @@
class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
def slack_disconnect
return unless smtp_config_set_or_development?
subject = 'Your Slack integration has expired'
@action_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/integrations/slack"
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
def dialogflow_disconnect
return unless smtp_config_set_or_development?
subject = 'Your Dialogflow integration was disconnected'
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
class AdministratorNotifications::ChannelNotificationsMailer < AdministratorNotifications::BaseMailer
def facebook_disconnect(inbox)
return unless smtp_config_set_or_development?
subject = 'Your Facebook page connection has expired'
@action_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/inboxes/#{inbox.id}"
send_mail_with_liquid(to: admin_emails, subject: subject) and return
send_notification(subject, action_url: inbox_url(inbox))
end
def instagram_disconnect(inbox)
subject = 'Your Instagram connection has expired'
send_notification(subject, action_url: inbox_url(inbox))
end
def whatsapp_disconnect(inbox)
return unless smtp_config_set_or_development?
subject = 'Your Whatsapp connection has expired'
@action_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/inboxes/#{inbox.id}"
send_mail_with_liquid(to: admin_emails, subject: subject) and return
send_notification(subject, action_url: inbox_url(inbox))
end
def email_disconnect(inbox)
return unless smtp_config_set_or_development?
subject = 'Your email inbox has been disconnected. Please update the credentials for SMTP/IMAP'
@action_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/inboxes/#{inbox.id}"
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
def contact_import_complete(resource)
return unless smtp_config_set_or_development?
subject = 'Contact Import Completed'
@action_url = Rails.application.routes.url_helpers.rails_blob_url(resource.failed_records) if resource.failed_records.attached?
@action_url ||= "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{resource.account.id}/contacts"
@meta = {}
@meta['failed_contacts'] = resource.total_records - resource.processed_records
@meta['imported_contacts'] = resource.processed_records
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
def contact_import_failed
return unless smtp_config_set_or_development?
subject = 'Contact Import Failed'
@meta = {}
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
def contact_export_complete(file_url, email_to)
return unless smtp_config_set_or_development?
@action_url = file_url
subject = "Your contact's export file is available to download."
send_mail_with_liquid(to: email_to, subject: subject) and return
end
def automation_rule_disabled(rule)
return unless smtp_config_set_or_development?
@action_url ||= "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/automation/list"
subject = 'Automation rule disabled due to validation errors.'.freeze
@meta = {}
@meta['rule_name'] = rule.name
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
private
def admin_emails
Current.account.administrators.pluck(:email)
end
def liquid_locals
super.merge({ meta: @meta })
send_notification(subject, action_url: inbox_url(inbox))
end
end
@@ -0,0 +1,12 @@
class AdministratorNotifications::IntegrationsNotificationMailer < AdministratorNotifications::BaseMailer
def slack_disconnect
subject = 'Your Slack integration has expired'
action_url = settings_url('integrations/slack')
send_notification(subject, action_url: action_url)
end
def dialogflow_disconnect
subject = 'Your Dialogflow integration was disconnected'
send_notification(subject)
end
end
+14 -14
View File
@@ -2,20 +2,19 @@
#
# Table name: accounts
#
# id :integer not null, primary key
# auto_resolve_duration :integer
# contactable_contacts_count :integer default(0)
# custom_attributes :jsonb
# domain :string(100)
# feature_flags :bigint default(0), not null
# internal_attributes :jsonb not null
# limits :jsonb
# locale :integer default("en")
# name :string not null
# status :integer default("active")
# support_email :string(100)
# created_at :datetime not null
# updated_at :datetime not null
# id :integer not null, primary key
# auto_resolve_duration :integer
# custom_attributes :jsonb
# domain :string(100)
# feature_flags :bigint default(0), not null
# internal_attributes :jsonb not null
# limits :jsonb
# locale :integer default("en")
# name :string not null
# status :integer default("active")
# support_email :string(100)
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
@@ -162,5 +161,6 @@ class Account < ApplicationRecord
end
Account.prepend_mod_with('Account')
Account.prepend_mod_with('Account::PlanUsageAndLimits')
Account.include_mod_with('Concerns::Account')
Account.include_mod_with('Audit::Account')
+37 -1
View File
@@ -16,13 +16,49 @@
#
class Channel::Instagram < ApplicationRecord
include Channelable
include Reauthorizable
self.table_name = 'channel_instagram'
AUTHORIZATION_ERROR_THRESHOLD = 1
validates :access_token, presence: true
validates :instagram_id, uniqueness: true, presence: true
after_create_commit :subscribe
before_destroy :unsubscribe
def name
'Instagram'
end
def subscribe
# ref https://developers.facebook.com/docs/instagram-platform/webhooks#enable-subscriptions
HTTParty.post(
"https://graph.instagram.com/v22.0/#{instagram_id}/subscribed_apps",
query: {
subscribed_fields: %w[messages message_reactions messaging_seen],
access_token: access_token
}
)
rescue StandardError => e
Rails.logger.debug { "Rescued: #{e.inspect}" }
true
end
def unsubscribe
HTTParty.delete(
"https://graph.instagram.com/v22.0/#{instagram_id}/subscribed_apps",
query: {
access_token: access_token
}
)
true
rescue StandardError => e
Rails.logger.debug { "Rescued: #{e.inspect}" }
true
end
def access_token
Instagram::RefreshOauthTokenService.new(channel: self).access_token
end
end
+24 -18
View File
@@ -39,33 +39,28 @@ module Reauthorizable
def prompt_reauthorization!
::Redis::Alfred.set(reauthorization_required_key, true)
mailer = AdministratorNotifications::ChannelNotificationsMailer.with(account: account)
case self.class.name
when 'Integrations::Hook'
process_integration_hook_reauthorization_emails(mailer)
when 'Channel::FacebookPage'
mailer.facebook_disconnect(inbox).deliver_later
when 'Channel::Whatsapp'
mailer.whatsapp_disconnect(inbox).deliver_later
when 'Channel::Email'
mailer.email_disconnect(inbox).deliver_later
when 'AutomationRule'
update!(active: false)
mailer.automation_rule_disabled(self).deliver_later
end
reauthorization_handlers[self.class.name]&.call(self)
invalidate_inbox_cache unless instance_of?(::AutomationRule)
end
def process_integration_hook_reauthorization_emails(mailer)
def process_integration_hook_reauthorization_emails
if slack?
mailer.slack_disconnect.deliver_later
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).slack_disconnect.deliver_later
elsif dialogflow?
mailer.dialogflow_disconnect.deliver_later
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).dialogflow_disconnect.deliver_later
end
end
def send_channel_reauthorization_email(disconnect_type)
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).public_send(disconnect_type, inbox).deliver_later
end
def handle_automation_rule_reauthorization
update!(active: false)
AdministratorNotifications::AccountNotificationMailer.with(account: account).automation_rule_disabled(self).deliver_later
end
# call this after you successfully Reauthorized the object in UI
def reauthorized!
::Redis::Alfred.delete(authorization_error_count_key)
@@ -76,6 +71,17 @@ module Reauthorizable
private
def reauthorization_handlers
{
'Integrations::Hook' => ->(obj) { obj.process_integration_hook_reauthorization_emails },
'Channel::FacebookPage' => ->(obj) { obj.send_channel_reauthorization_email(:facebook_disconnect) },
'Channel::Instagram' => ->(obj) { obj.send_channel_reauthorization_email(:instagram_disconnect) },
'Channel::Whatsapp' => ->(obj) { obj.send_channel_reauthorization_email(:whatsapp_disconnect) },
'Channel::Email' => ->(obj) { obj.send_channel_reauthorization_email(:email_disconnect) },
'AutomationRule' => ->(obj) { obj.handle_automation_rule_reauthorization }
}
end
def invalidate_inbox_cache
inbox.update_account_cache if inbox.present?
end
+5 -1
View File
@@ -107,7 +107,11 @@ class Inbox < ApplicationRecord
end
def instagram?
facebook? && channel.instagram_id.present?
(facebook? || instagram_direct?) && channel.instagram_id.present?
end
def instagram_direct?
channel_type == 'Channel::Instagram'
end
def web_widget?
-10
View File
@@ -30,14 +30,6 @@ class Portal < ApplicationRecord
has_many :categories, dependent: :destroy_async
has_many :folders, through: :categories
has_many :articles, dependent: :destroy_async
has_many :portal_members,
class_name: :PortalMember,
dependent: :destroy_async
has_many :members,
through: :portal_members,
class_name: :User,
dependent: :nullify,
source: :user
has_one_attached :logo
has_many :inboxes, dependent: :nullify
belongs_to :channel_web_widget, class_name: 'Channel::WebWidget', optional: true
@@ -49,8 +41,6 @@ class Portal < ApplicationRecord
validates :custom_domain, uniqueness: true, allow_nil: true
validate :config_json_format
accepts_nested_attributes_for :members
scope :active, -> { where(archived: false) }
CONFIG_JSON_KEYS = %w[allowed_locales default_locale website_token].freeze
-20
View File
@@ -1,20 +0,0 @@
# == Schema Information
#
# Table name: portal_members
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# portal_id :bigint
# user_id :bigint
#
# Indexes
#
# index_portal_members_on_portal_id_and_user_id (portal_id,user_id) UNIQUE
# index_portal_members_on_user_id_and_portal_id (user_id,portal_id) UNIQUE
#
class PortalMember < ApplicationRecord
belongs_to :portal, class_name: 'Portal'
belongs_to :user, class_name: 'User'
validates :user_id, uniqueness: { scope: :portal_id }
end
-4
View File
@@ -95,10 +95,6 @@ class User < ApplicationRecord
has_many :team_members, dependent: :destroy_async
has_many :teams, through: :team_members
has_many :articles, foreign_key: 'author_id', dependent: :nullify, inverse_of: :author
has_many :portal_members, class_name: :PortalMember, dependent: :destroy_async
has_many :portals, through: :portal_members, source: :portal,
class_name: :Portal,
dependent: :nullify
# rubocop:disable Rails/HasManyOrHasOneDependent
# we are handling this in `remove_macros` callback
has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
+4
View File
@@ -26,4 +26,8 @@ class AccountPolicy < ApplicationPolicy
def checkout?
@account_user.administrator?
end
def toggle_deletion?
@account_user.administrator?
end
end
+8 -14
View File
@@ -1,37 +1,31 @@
class ArticlePolicy < ApplicationPolicy
def index?
@account_user.administrator? || @account.users.include?(@user)
@account.users.include?(@user)
end
def update?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def show?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def edit?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def create?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def destroy?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def reorder?
@account_user.administrator? || portal_member?
end
private
def portal_member?
@record.first.portal.members.include?(@user)
@account_user.administrator?
end
end
ArticlePolicy.prepend_mod_with('Enterprise::ArticlePolicy')
ArticlePolicy.prepend_mod_with('ArticlePolicy')
+7 -13
View File
@@ -1,33 +1,27 @@
class CategoryPolicy < ApplicationPolicy
def index?
@account_user.administrator? || @account.users.include?(@user)
@account.users.include?(@user)
end
def update?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def show?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def edit?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def create?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def destroy?
@account_user.administrator? || portal_member?
end
private
def portal_member?
@record.first.portal.members.include?(@user)
@account_user.administrator?
end
end
CategoryPolicy.prepend_mod_with('Enterprise::CategoryPolicy')
CategoryPolicy.prepend_mod_with('CategoryPolicy')
+3 -13
View File
@@ -1,6 +1,6 @@
class PortalPolicy < ApplicationPolicy
def index?
@account_user.administrator? || @account.users.include?(@user)
@account.users.include?(@user)
end
def update?
@@ -8,7 +8,7 @@ class PortalPolicy < ApplicationPolicy
end
def show?
@account_user.administrator? || portal_member?
@account.users.include?(@user)
end
def edit?
@@ -23,19 +23,9 @@ class PortalPolicy < ApplicationPolicy
@account_user.administrator?
end
def add_members?
@account_user.administrator?
end
def logo?
@account_user.administrator?
end
private
def portal_member?
@record.first.members.include?(@user)
end
end
PortalPolicy.prepend_mod_with('Enterprise::PortalPolicy')
PortalPolicy.prepend_mod_with('PortalPolicy')
+1 -1
View File
@@ -4,4 +4,4 @@ class ReportPolicy < ApplicationPolicy
end
end
ReportPolicy.prepend_mod_with('Enterprise::ReportPolicy')
ReportPolicy.prepend_mod_with('ReportPolicy')
@@ -0,0 +1,84 @@
# Service to handle Instagram access token refresh logic
# Instagram tokens are valid for 60 days and can be refreshed to extend validity
# This service implements the refresh logic per official Instagram API guidelines
class Instagram::RefreshOauthTokenService
attr_reader :channel
def initialize(channel:)
@channel = channel
end
# Returns a valid access token, refreshing it if necessary and eligible
def access_token
return unless token_valid?
# If token is valid and eligible for refresh, attempt to refresh it
return channel[:access_token] unless token_eligible_for_refresh?
attempt_token_refresh
end
private
# Checks if the current token is still valid (not expired)
def token_valid?
return false if channel.expires_at.blank?
# Check if token is still valid
Time.current < channel.expires_at
end
# Determines if a token is eligible for refresh based on Instagram's requirements
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#refresh-a-long-lived-token
def token_eligible_for_refresh?
# Three conditions must be met:
# 1. Token is still valid
token_is_valid = Time.current < channel.expires_at
# 2. Token is at least 24 hours old (based on updated_at)
token_is_old_enough = channel.updated_at.present? && channel.updated_at < 24.hours.ago
# 3. Token is approaching expiry (within 10 days)
approaching_expiry = channel.expires_at < 10.days.from_now
token_is_valid && token_is_old_enough && approaching_expiry
end
# Makes an API request to refresh the long-lived token
# @return [Hash] Response data containing new access_token and expires_in values
# @raise [RuntimeError] If API request fails
def refresh_long_lived_token
endpoint = 'https://graph.instagram.com/refresh_access_token'
params = {
grant_type: 'ig_refresh_token',
access_token: channel[:access_token]
}
response = HTTParty.get(endpoint, query: params, headers: { 'Accept' => 'application/json' })
unless response.success?
Rails.logger.error "Failed to refresh Instagram token: #{response.body}"
raise "Failed to refresh Instagram token: #{response.body}"
end
JSON.parse(response.body)
end
def update_channel_tokens(token_data)
channel.update!(
access_token: token_data['access_token'],
expires_at: Time.current + token_data['expires_in'].seconds
)
end
# Attempts to refresh the token, returning either the new or existing token
def attempt_token_refresh
refreshed_token_data = refresh_long_lived_token
update_channel_tokens(refreshed_token_data)
channel.reload[:access_token]
rescue StandardError => e
Rails.logger.error("Token refresh failed: #{e.message}")
channel[:access_token]
end
end
@@ -25,14 +25,6 @@ end
json.logo portal.file_base_data if portal.logo.present?
json.portal_members do
if portal.members.any?
json.array! portal.members.each do |member|
json.partial! 'api/v1/models/agent', formats: [:json], resource: member
end
end
end
json.meta do
json.all_articles_count articles.try(:size)
json.archived_articles_count articles.try(:archived).try(:size)
@@ -11,6 +11,10 @@ if resource.custom_attributes.present?
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present?
json.logo resource.custom_attributes['logo'] if resource.custom_attributes['logo'].present?
json.onboarding_step resource.custom_attributes['onboarding_step'] if resource.custom_attributes['onboarding_step'].present?
json.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']
end
end
end
json.domain @account.domain
@@ -54,6 +54,9 @@ if resource.facebook?
json.reauthorization_required resource.channel.try(:reauthorization_required?)
end
## Instagram Attributes
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.instagram?
## Twilio Attributes
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
json.phone_number resource.channel.try(:phone_number)
@@ -0,0 +1,16 @@
<p>Hello,</p>
<p>Your account <strong>{{ meta.account_name }}</strong> has been marked for deletion. The account will be permanently deleted on <strong>{{ meta.deletion_date }}</strong>.</p>
{% if meta.reason == 'manual_deletion' %}
<p>This action was requested by one of the administrators of your account.</p>
{% else %}
<p>Reason for deletion: {{ meta.reason }}</p>
{% endif %}
<p>If this was done in error, you can cancel the deletion process by visiting your account settings.</p>
<p><a href="{{ action_url }}">Cancel Account Deletion</a></p>
<p>Thank you,<br>
Team Chatwoot</p>
@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>Your Instagram Inbox Access has expired. </p>
<p>Please reconnect Instagram to continue receiving messages.</p>
<p>
Click <a href="{{action_url}}">here</a> to re-connect.
</p>
@@ -151,8 +151,12 @@
<symbol id="icon-linear" viewBox="0 0 24 24">
<path d="M0.294 14.765c-0.053-0.228 0.218-0.371 0.383-0.206l8.762 8.762c0.165 0.165 0.022 0.436-0.206 0.383C4.812 22.668 1.332 19.188 0.294 14.765zM0 11.253c-0.004 0.068 0.021 0.134 0.07 0.183l12.494 12.494c0.048 0.048 0.115 0.074 0.183 0.07c0.568-0.035 1.127-0.11 1.671-0.222c0.183-0.038 0.247-0.263 0.115-0.396l-13.847-13.847c-0.132-0.132-0.358-0.068-0.396 0.115c-0.112 0.544-0.187 1.102-0.222 1.671zM1.011 7.129c-0.04 0.09-0.02 0.195 0.05 0.264l15.546 15.546c0.069 0.069 0.174 0.09 0.264 0.05c0.429-0.191 0.844-0.406 1.244-0.644c0.133-0.079 0.153-0.261 0.044-0.37l-16.134-16.134c-0.109-0.109-0.291-0.089-0.37 0.044c-0.238 0.4-0.453 0.816-0.644 1.244zM3.038 4.338c-0.089-0.089-0.094-0.231-0.011-0.325c2.2-2.46 5.4-4.013 8.973-4.013 6.627 0 12 5.373 12 12c0 3.562-1.55 6.76-4.013 8.961c-0.094 0.084-0.236 0.078-0.325-0.011l-16.624-16.612z"/>
</symbol>
<symbol id="icon-instagram" viewBox="0 0 24 24">
<path d="M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z"/>
</symbol>
<symbol id="icon-shopify" viewBox="0 0 32 32">
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+4
View File
@@ -161,3 +161,7 @@
- name: search_with_gin
display_name: Search messages with GIN
enabled: false
- name: channel_instagram
display_name: Instagram Channel
enabled: false
chatwoot_internal: true
+27
View File
@@ -292,3 +292,30 @@
locked: false
type: secret
# ------- End of Shopify Related Config ------- #
# ------- Instagram Channel Related Config ------- #
- name: INSTAGRAM_APP_ID
display_title: 'Instagram App ID'
locked: false
- name: INSTAGRAM_APP_SECRET
display_title: 'Instagram App Secret'
description: 'The App Secret used for Instagram authentication'
locked: false
type: secret
- name: INSTAGRAM_VERIFY_TOKEN
display_title: 'Instagram Verify Token'
description: 'The verify token used for Instagram Webhook'
locked: false
type: secret
- name: ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT
display_title: 'Enable human agent for instagram channel'
value: false
locked: false
description: 'Enable human agent for instagram channel for longer message back period. Needs additional app approval: https://developers.facebook.com/docs/features-reference/human-agent/'
type: boolean
- name: INSTAGRAM_API_VERSION
display_title: 'Instagram API Version'
description: 'Configure this if you want to use a different Instagram API version. Make sure its prefixed with `v`'
value: 'v22.0'
locked: true
# ------- End of Instagram Channel Related Config ------- #
+9 -2
View File
@@ -18,8 +18,11 @@ Rails.application.routes.draw do
get '/app/*params', to: 'dashboard#index'
get '/app/accounts/:account_id/settings/inboxes/new/twitter', to: 'dashboard#index', as: 'app_new_twitter_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/microsoft', to: 'dashboard#index', as: 'app_new_microsoft_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/instagram', to: 'dashboard#index', as: 'app_new_instagram_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_twitter_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_email_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
resource :widget, only: [:show]
@@ -214,6 +217,10 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
namespace :instagram do
resource :authorization, only: [:create]
end
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resources :apps, only: [:index, :show]
@@ -257,7 +264,6 @@ Rails.application.routes.draw do
resources :portals do
member do
patch :archive
put :add_members
delete :logo
end
resources :categories
@@ -365,6 +371,7 @@ Rails.application.routes.draw do
post :checkout
post :subscription
get :limits
post :toggle_deletion
end
end
end
@@ -474,7 +481,7 @@ Rails.application.routes.draw do
get 'microsoft/callback', to: 'microsoft/callbacks#show'
get 'google/callback', to: 'google/callbacks#show'
get 'instagram/callback', to: 'instagram/callbacks#show'
# ----------------------------------------------------------------------
# Routes for external service verifications
get '.well-known/assetlinks.json' => 'android_app#assetlinks'
@@ -0,0 +1,16 @@
class RemovePortalMembers < ActiveRecord::Migration[7.0]
def up
drop_table :portal_members
end
def down
create_table :portal_members do |t|
t.references :portal, index: false
t.references :user, index: false
t.timestamps
end
add_index :portal_members, [:portal_id, :user_id], unique: true
add_index :portal_members, [:user_id, :portal_id], unique: true
end
end
+1 -10
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2025_03_26_034635) do
ActiveRecord::Schema[7.0].define(version: 2025_04_02_233933) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -871,15 +871,6 @@ ActiveRecord::Schema[7.0].define(version: 2025_03_26_034635) do
t.datetime "updated_at", null: false
end
create_table "portal_members", force: :cascade do |t|
t.bigint "portal_id"
t.bigint "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["portal_id", "user_id"], name: "index_portal_members_on_portal_id_and_user_id", unique: true
t.index ["user_id", "portal_id"], name: "index_portal_members_on_user_id_and_portal_id", unique: true
end
create_table "portals", force: :cascade do |t|
t.integer "account_id", null: false
t.string "name", null: false
@@ -2,7 +2,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
before_action :check_authorization
before_action :check_cloud_env, only: [:limits]
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
def subscription
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
@@ -42,13 +42,26 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
render_invalid_billing_details
end
def toggle_deletion
action_type = params[:action_type]
case action_type
when 'delete'
mark_for_deletion
when 'undelete'
unmark_for_deletion
else
render json: { error: 'Invalid action_type. Must be either "delete" or "undelete"' }, status: :unprocessable_entity
end
end
private
def check_cloud_env
installation_config = InstallationConfig.find_by(name: 'DEPLOYMENT_ENV')
render json: { error: 'Not found' }, status: :not_found unless installation_config&.value == 'cloud'
end
private
def default_limits
{
'conversation' => {},
@@ -67,6 +80,24 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
@account.custom_attributes['stripe_customer_id']
end
def mark_for_deletion
reason = 'manual_deletion'
if @account.mark_for_deletion(reason)
render json: { message: 'Account marked for deletion' }, status: :ok
else
render json: { message: @account.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def unmark_for_deletion
if @account.unmark_for_deletion
render json: { message: 'Account unmarked for deletion' }, status: :ok
else
render json: { message: @account.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def render_invalid_billing_details
render_could_not_create_error('Please subscribe to a plan before viewing the billing details')
end
@@ -1,7 +0,0 @@
module Enterprise::Api::V2::Accounts::ReportsController
def check_authorization
return if Current.account_user.custom_role&.permissions&.include?('report_manage')
super
end
end
@@ -91,3 +91,9 @@ shopify:
enabled: true
icon: 'icon-shopify'
config_key: 'shopify'
instagram:
name: 'Instagram'
description: 'Configuration for setting up Instagram'
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
+8 -124
View File
@@ -1,130 +1,14 @@
module Enterprise::Account
CAPTAIN_RESPONSES = 'captain_responses'.freeze
CAPTAIN_DOCUMENTS = 'captain_documents'.freeze
CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze
CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze
def mark_for_deletion(reason = 'manual_deletion')
result = custom_attributes.merge!('marked_for_deletion_at' => 7.days.from_now.iso8601, 'marked_for_deletion_reason' => reason) && save
def usage_limits
{
agents: agent_limits.to_i,
inboxes: get_limits(:inboxes).to_i,
captain: {
documents: get_captain_limits(:documents),
responses: get_captain_limits(:responses)
}
}
# Send notification to admin users if the account was successfully marked for deletion
AdministratorNotifications::AccountNotificationMailer.with(account: self).account_deletion(self, reason).deliver_later if result
result
end
def increment_response_usage
current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1
save
end
def reset_response_usage
custom_attributes[CAPTAIN_RESPONSES_USAGE] = 0
save
end
def update_document_usage
# this will ensure that the document count is always accurate
custom_attributes[CAPTAIN_DOCUMENTS_USAGE] = captain_documents.count
save
end
def subscribed_features
plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value
return [] if plan_features.blank?
plan_features[plan_name]
end
def captain_monthly_limit
default_limits = default_captain_limits
{
documents: self[:limits][CAPTAIN_DOCUMENTS] || default_limits['documents'],
responses: self[:limits][CAPTAIN_RESPONSES] || default_limits['responses']
}.with_indifferent_access
end
private
def get_captain_limits(type)
total_count = captain_monthly_limit[type.to_s].to_i
consumed = if type == :documents
custom_attributes[CAPTAIN_DOCUMENTS_USAGE].to_i || 0
else
custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
end
consumed = 0 if consumed.negative?
{
total_count: total_count,
current_available: (total_count - consumed).clamp(0, total_count),
consumed: consumed
}
end
def default_captain_limits
max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access
zero_limits = { documents: 0, responses: 0 }.with_indifferent_access
plan_quota = InstallationConfig.find_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS')&.value
# If there are no limits configured, we allow max usage
return max_limits if plan_quota.blank?
# if there is plan_quota configred, but plan_name is not present, we return zero limits
return zero_limits if plan_name.blank?
begin
# Now we parse the plan_quota and return the limits for the plan name
# but if there's no plan_name present in the plan_quota, we return zero limits
plan_quota = JSON.parse(plan_quota) if plan_quota.present?
plan_quota[plan_name.downcase] || zero_limits
rescue StandardError
# if there's any error in parsing the plan_quota, we return max limits
# this is to ensure that we don't block the user from using the product
max_limits
end
end
def plan_name
custom_attributes['plan_name']
end
def agent_limits
subscribed_quantity = custom_attributes['subscribed_quantity']
subscribed_quantity || get_limits(:agents)
end
def get_limits(limit_name)
config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT"
return self[:limits][limit_name.to_s] if self[:limits][limit_name.to_s].present?
return GlobalConfig.get(config_name)[config_name] if GlobalConfig.get(config_name)[config_name].present?
ChatwootApp.max_limit
end
def validate_limit_keys
errors.add(:limits, ': Invalid data') unless self[:limits].is_a? Hash
self[:limits] = {} if self[:limits].blank?
limit_schema = {
'type' => 'object',
'properties' => {
'inboxes' => { 'type': 'number' },
'agents' => { 'type': 'number' },
'captain_responses' => { 'type': 'number' },
'captain_documents' => { 'type': 'number' }
},
'required' => [],
'additionalProperties' => false
}
errors.add(:limits, ': Invalid data') unless JSONSchemer.schema(limit_schema).valid?(self[:limits])
def unmark_for_deletion
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
end
end
@@ -0,0 +1,130 @@
module Enterprise::Account::PlanUsageAndLimits
CAPTAIN_RESPONSES = 'captain_responses'.freeze
CAPTAIN_DOCUMENTS = 'captain_documents'.freeze
CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze
CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze
def usage_limits
{
agents: agent_limits.to_i,
inboxes: get_limits(:inboxes).to_i,
captain: {
documents: get_captain_limits(:documents),
responses: get_captain_limits(:responses)
}
}
end
def increment_response_usage
current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1
save
end
def reset_response_usage
custom_attributes[CAPTAIN_RESPONSES_USAGE] = 0
save
end
def update_document_usage
# this will ensure that the document count is always accurate
custom_attributes[CAPTAIN_DOCUMENTS_USAGE] = captain_documents.count
save
end
def subscribed_features
plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value
return [] if plan_features.blank?
plan_features[plan_name]
end
def captain_monthly_limit
default_limits = default_captain_limits
{
documents: self[:limits][CAPTAIN_DOCUMENTS] || default_limits['documents'],
responses: self[:limits][CAPTAIN_RESPONSES] || default_limits['responses']
}.with_indifferent_access
end
private
def get_captain_limits(type)
total_count = captain_monthly_limit[type.to_s].to_i
consumed = if type == :documents
custom_attributes[CAPTAIN_DOCUMENTS_USAGE].to_i || 0
else
custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
end
consumed = 0 if consumed.negative?
{
total_count: total_count,
current_available: (total_count - consumed).clamp(0, total_count),
consumed: consumed
}
end
def default_captain_limits
max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access
zero_limits = { documents: 0, responses: 0 }.with_indifferent_access
plan_quota = InstallationConfig.find_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS')&.value
# If there are no limits configured, we allow max usage
return max_limits if plan_quota.blank?
# if there is plan_quota configred, but plan_name is not present, we return zero limits
return zero_limits if plan_name.blank?
begin
# Now we parse the plan_quota and return the limits for the plan name
# but if there's no plan_name present in the plan_quota, we return zero limits
plan_quota = JSON.parse(plan_quota) if plan_quota.present?
plan_quota[plan_name.downcase] || zero_limits
rescue StandardError
# if there's any error in parsing the plan_quota, we return max limits
# this is to ensure that we don't block the user from using the product
max_limits
end
end
def plan_name
custom_attributes['plan_name']
end
def agent_limits
subscribed_quantity = custom_attributes['subscribed_quantity']
subscribed_quantity || get_limits(:agents)
end
def get_limits(limit_name)
config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT"
return self[:limits][limit_name.to_s] if self[:limits][limit_name.to_s].present?
return GlobalConfig.get(config_name)[config_name] if GlobalConfig.get(config_name)[config_name].present?
ChatwootApp.max_limit
end
def validate_limit_keys
errors.add(:limits, ': Invalid data') unless self[:limits].is_a? Hash
self[:limits] = {} if self[:limits].blank?
limit_schema = {
'type' => 'object',
'properties' => {
'inboxes' => { 'type': 'number' },
'agents' => { 'type': 'number' },
'captain_responses' => { 'type': 'number' },
'captain_documents' => { 'type': 'number' }
},
'required' => [],
'additionalProperties' => false
}
errors.add(:limits, ': Invalid data') unless JSONSchemer.schema(limit_schema).valid?(self[:limits])
end
end
@@ -1,32 +1,12 @@
module Enterprise::PortalPolicy
def index?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def update?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def show?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def edit?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def create?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def destroy?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def add_members?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def logo?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
@@ -3,12 +3,11 @@ require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id) }
let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, locale: 'en', slug: 'category_slug') }
let!(:article) { create(:article, category: category, portal: portal, account_id: account.id, author_id: agent.id) }
before { create(:portal_member, user: agent, portal: portal) }
describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/articles' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -33,7 +32,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
}
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
@@ -56,7 +55,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
}
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
@@ -84,7 +83,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
}
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
@@ -110,7 +109,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
}
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
@@ -144,7 +143,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
params: article_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql(article_params[:article][:title])
@@ -165,7 +164,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
context 'when it is an authenticated user' do
it 'deletes category' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
deleted_article = Article.find_by(id: article.id)
expect(deleted_article).to be_nil
@@ -187,7 +186,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
headers: agent.create_new_auth_token,
headers: admin.create_new_auth_token,
params: {}
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -199,7 +198,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
headers: agent.create_new_auth_token,
headers: admin.create_new_auth_token,
params: {}
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -213,7 +212,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
headers: agent.create_new_auth_token,
headers: admin.create_new_auth_token,
params: { category_slug: category.slug }
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -230,14 +229,14 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
headers: agent.create_new_auth_token,
headers: admin.create_new_auth_token,
params: { query: 'funny' }
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].count).to be 1
expect(json_response['meta']['all_articles_count']).to be 2
expect(json_response['meta']['articles_count']).to be 1
expect(json_response['meta']['mine_articles_count']).to be 1
expect(json_response['meta']['mine_articles_count']).to be 0
end
end
@@ -247,7 +246,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article2.id}",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -263,7 +262,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
associated_article_id: root_article.id)
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{root_article.id}",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -3,6 +3,7 @@ require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id, config: { allowed_locales: %w[en es] }) }
let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, slug: 'category_slug', position: 1) }
let!(:category_to_associate) do
@@ -15,8 +16,6 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
create(:category, name: 'related category 2', portal: portal, account_id: account.id, slug: 'category_slug_2', position: 4)
end
before { create(:portal_member, user: agent, portal: portal) }
describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/categories' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -59,7 +58,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
it 'creates category' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -75,11 +74,11 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
it 'creates multiple sub_categories under one parent_category' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params_2,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(category.reload.sub_category_ids).to eql(Category.last(2).pluck(:id))
@@ -88,11 +87,11 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
it 'creates multiple associated_categories with one category' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params_2,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(category_to_associate.reload.associated_category_ids).to eql(Category.last(2).pluck(:id))
@@ -101,11 +100,11 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
it 'will throw an error on locale, category_id uniqueness' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['message']).to eql('Locale should be unique in the category and portal')
@@ -123,7 +122,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
@@ -158,7 +157,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
json_response = response.parsed_body
@@ -181,7 +180,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
@@ -209,7 +208,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{related_category_2.id}",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
@@ -230,7 +229,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
context 'when it is an authenticated user' do
it 'deletes category' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
deleted_category = Category.find_by(id: category.id)
expect(deleted_category).to be_nil
@@ -255,7 +254,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
expect(category2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].count).to be(category_count + 1)
@@ -0,0 +1,54 @@
require 'rails_helper'
RSpec.describe 'Instagram Authorization API', type: :request do
let(:account) { create(:account) }
describe 'POST /api/v1/accounts/{account.id}/instagram/authorization' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/instagram/authorization"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
it 'returns unauthorized for agent' do
post "/api/v1/accounts/#{account.id}/instagram/authorization",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'creates a new authorization and returns the redirect url' do
post "/api/v1/accounts/#{account.id}/instagram/authorization",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['success']).to be true
instagram_service = Class.new do
extend InstagramConcern
extend Instagram::IntegrationHelper
end
frontend_url = ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
response_url = instagram_service.instagram_client.auth_code.authorize_url(
{
redirect_uri: "#{frontend_url}/instagram/callback",
scope: Instagram::IntegrationHelper::REQUIRED_SCOPES.join(','),
enable_fb_login: '0',
force_authentication: '1',
response_type: 'code',
state: instagram_service.generate_instagram_token(account.id)
}
)
expect(response.parsed_body['url']).to eq response_url
end
end
end
end
@@ -8,8 +8,6 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
let(:agent_2) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, slug: 'portal-1', name: 'test_portal', account_id: account.id) }
before { create(:portal_member, user: agent, portal: portal) }
describe 'GET /api/v1/accounts/{account.id}/portals' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -23,7 +21,7 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
portal2 = create(:portal, name: 'test_portal_2', account_id: account.id, slug: 'portal-2')
expect(portal2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -45,7 +43,7 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
context 'when it is an authenticated user' do
it 'get one portals' do
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -62,7 +60,7 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
create(:article, category_id: es_cat.id, portal_id: portal.id, author_id: agent.id)
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}?locale=en",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -178,38 +176,7 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
end
end
describe 'PUT /api/v1/accounts/{account.id}/portals/{portal.slug}/add_members' do
let(:new_account) { create(:account) }
let(:new_agent) { create(:user, account: new_account, role: :agent) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/add_members", params: {}
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'add members to the portal' do
portal_params = {
portal: {
member_ids: [agent_1.id, agent_2.id]
}
}
expect(portal.members.count).to be(1)
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/add_members",
params: portal_params,
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(portal.reload.member_ids).to include(agent_1.id)
expect(json_response['portal_members'].length).to be(3)
end
end
end
# Portal members endpoint removed
describe 'DELETE /api/v1/accounts/{account.id}/portals/{portal.slug}/logo' do
context 'when it is an unauthenticated user' do
@@ -0,0 +1,138 @@
require 'rails_helper'
RSpec.describe InstagramConcern do
let(:dummy_class) { Class.new { include InstagramConcern } }
let(:dummy_instance) { dummy_class.new }
let(:client_id) { 'test_client_id' }
let(:client_secret) { 'test_client_secret' }
let(:short_lived_token) { 'short_lived_token' }
let(:long_lived_token) { 'long_lived_token' }
let(:access_token) { 'access_token' }
before do
allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_ID', nil).and_return(client_id)
allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_SECRET', nil).and_return(client_secret)
allow(Rails.logger).to receive(:error)
end
describe '#instagram_client' do
it 'creates an OAuth2 client with correct configuration', :aggregate_failures do
client = dummy_instance.instagram_client
expect(client).to be_a(OAuth2::Client)
expect(client.id).to eq(client_id)
expect(client.secret).to eq(client_secret)
expect(client.site).to eq('https://api.instagram.com')
expect(client.options[:authorize_url]).to eq('https://api.instagram.com/oauth/authorize')
expect(client.options[:token_url]).to eq('https://api.instagram.com/oauth/access_token')
expect(client.options[:auth_scheme]).to eq(:request_body)
expect(client.options[:token_method]).to eq(:post)
end
end
describe '#exchange_for_long_lived_token' do
let(:response_body) { { 'access_token' => long_lived_token, 'expires_in' => 5_184_000 }.to_json }
let(:mock_response) { instance_double(HTTParty::Response, body: response_body, success?: true) }
before do
allow(HTTParty).to receive(:get).and_return(mock_response)
allow(mock_response).to receive(:inspect).and_return(response_body)
end
it 'exchanges short lived token for long lived token' do
result = dummy_instance.send(:exchange_for_long_lived_token, short_lived_token)
expect(HTTParty).to have_received(:get).with(
'https://graph.instagram.com/access_token',
{
query: {
grant_type: 'ig_exchange_token',
client_secret: client_secret,
access_token: short_lived_token,
client_id: client_id
},
headers: { 'Accept' => 'application/json' }
}
)
expect(result).to eq({ 'access_token' => long_lived_token, 'expires_in' => 5_184_000 })
end
context 'when the request fails' do
let(:mock_response) { instance_double(HTTParty::Response, body: 'Error', success?: false, code: 400) }
it 'raises an error' do
expect do
dummy_instance.send(:exchange_for_long_lived_token, short_lived_token)
end.to raise_error(RuntimeError, 'Failed to exchange token: Error')
end
end
context 'when the response is not valid JSON' do
let(:mock_response) { instance_double(HTTParty::Response, body: 'Not JSON', success?: true) }
it 'raises a JSON parse error' do
allow(JSON).to receive(:parse).and_raise(JSON::ParserError.new('Invalid JSON'))
expect { dummy_instance.send(:exchange_for_long_lived_token, short_lived_token) }.to raise_error(JSON::ParserError)
end
end
end
describe '#fetch_instagram_user_details' do
let(:user_details) do
{
'id' => '12345',
'username' => 'test_user',
'user_id' => '12345',
'name' => 'Test User',
'profile_picture_url' => 'https://example.com/profile.jpg',
'account_type' => 'BUSINESS'
}
end
let(:response_body) { user_details.to_json }
let(:mock_response) { instance_double(HTTParty::Response, body: response_body, success?: true) }
before do
allow(HTTParty).to receive(:get).and_return(mock_response)
allow(mock_response).to receive(:inspect).and_return(response_body)
end
it 'fetches Instagram user details' do
result = dummy_instance.send(:fetch_instagram_user_details, access_token)
expect(HTTParty).to have_received(:get).with(
'https://graph.instagram.com/v22.0/me',
{
query: {
fields: 'id,username,user_id,name,profile_picture_url,account_type',
access_token: access_token
},
headers: { 'Accept' => 'application/json' }
}
)
expect(result).to eq(user_details)
end
context 'when the request fails' do
let(:mock_response) { instance_double(HTTParty::Response, body: 'Error', success?: false, code: 400) }
it 'raises an error' do
expect do
dummy_instance.send(:fetch_instagram_user_details, access_token)
end.to raise_error(RuntimeError, 'Failed to fetch Instagram user details: Error')
end
end
context 'when the response is not valid JSON' do
let(:mock_response) { instance_double(HTTParty::Response, body: 'Not JSON', success?: true) }
it 'raises a JSON parse error' do
allow(JSON).to receive(:parse).and_raise(JSON::ParserError.new('Invalid JSON'))
expect { dummy_instance.send(:fetch_instagram_user_details, access_token) }.to raise_error(JSON::ParserError)
end
end
end
end
@@ -0,0 +1,113 @@
require 'rails_helper'
RSpec.describe Instagram::CallbacksController do
let(:account) { create(:account) }
let(:valid_params) { { code: 'valid_code', state: "#{account.id}|valid_token" } }
let(:error_params) { { error: 'access_denied', error_description: 'User denied access', state: "#{account.id}|valid_token" } }
let(:oauth_client) { instance_double(OAuth2::Client) }
let(:auth_code_object) { instance_double(OAuth2::Strategy::AuthCode) }
let(:access_token) { instance_double(OAuth2::AccessToken, token: 'test_token') }
let(:long_lived_token_response) { { 'access_token' => 'long_lived_test_token', 'expires_in' => 5_184_000 } }
let(:user_details) { { 'username' => 'test_user', 'user_id' => '12345' } }
let(:exception_tracker) { instance_double(ChatwootExceptionTracker) }
before do
allow(controller).to receive(:verify_instagram_token).and_return(account.id)
allow(controller).to receive(:instagram_client).and_return(oauth_client)
allow(controller).to receive(:base_url).and_return('https://app.chatwoot.com')
allow(controller).to receive(:account).and_return(account)
allow(oauth_client).to receive(:auth_code).and_return(auth_code_object)
allow(controller).to receive(:exchange_for_long_lived_token).and_return(long_lived_token_response)
allow(controller).to receive(:fetch_instagram_user_details).and_return(user_details)
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
allow(exception_tracker).to receive(:capture_exception)
# Stub the exact request format that's being made
stub_request(:post, 'https://graph.instagram.com/v22.0/12345/subscribed_apps?access_token=long_lived_test_token&subscribed_fields%5B%5D=messages&subscribed_fields%5B%5D=message_reactions&subscribed_fields%5B%5D=messaging_seen')
.with(
headers: {
'Accept' => '*/*',
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
'User-Agent' => 'Ruby'
}
)
.to_return(status: 200, body: '', headers: {})
end
describe '#show' do
context 'when authorization is successful' do
before do
allow(auth_code_object).to receive(:get_token).and_return(access_token)
end
it 'creates instagram channel and inbox' do
expect do
get :show, params: valid_params
end.to change(Channel::Instagram, :count).by(1).and change(Inbox, :count).by(1)
expect(Channel::Instagram.last.access_token).to eq('long_lived_test_token')
expect(Channel::Instagram.last.instagram_id).to eq('12345')
expect(Inbox.last.name).to eq('test_user')
expect(response).to redirect_to(app_instagram_inbox_agents_url(account_id: account.id, inbox_id: Inbox.last.id))
end
end
context 'when user denies authorization' do
it 'redirects to error page with authorization error details' do
get :show, params: error_params
expect(response).to redirect_to(
app_new_instagram_inbox_url(
account_id: account.id,
error_type: 'access_denied',
code: 400,
error_message: 'User denied access'
)
)
end
end
context 'when an OAuth error occurs' do
before do
oauth_error = OAuth2::Error.new(
OpenStruct.new(
body: { error_type: 'OAuthException', code: 400, error_message: 'Invalid OAuth code' }.to_json,
status: 400
)
)
allow(auth_code_object).to receive(:get_token).and_raise(oauth_error)
end
it 'handles OAuth errors and redirects to error page' do
get :show, params: valid_params
expected_url = app_new_instagram_inbox_url(
account_id: account.id,
error_type: 'OAuthException',
code: 400,
error_message: 'Invalid OAuth code'
)
expect(response).to redirect_to(expected_url)
end
end
context 'when a standard error occurs' do
before do
allow(auth_code_object).to receive(:get_token).and_raise(StandardError.new('Unknown error'))
end
it 'handles standard errors and redirects to error page' do
get :show, params: valid_params
expected_url = app_new_instagram_inbox_url(
account_id: account.id,
error_type: 'StandardError',
code: 500,
error_message: 'Unknown error'
)
expect(response).to redirect_to(expected_url)
end
end
end
end
@@ -0,0 +1,103 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise Articles API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id) }
let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, locale: 'en', slug: 'category_slug') }
let!(:article) { create(:article, category: category, portal: portal, account_id: account.id, author_id: admin.id) }
# Create a custom role with knowledge_base_manage permission
let!(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
# Create user without account
let!(:agent_with_role) { create(:user) }
# Then create account_user association with custom_role
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
# Ensure the account_user with custom role is created before tests run
before do
agent_with_role_account_user
end
describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug/articles/:id' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
describe 'POST /api/v1/accounts/:account_id/portals/:portal_slug/articles' do
let(:article_params) do
{
article: {
category_id: category.id,
title: 'New Article',
slug: 'new-article',
content: 'This is a new article',
author_id: agent_with_role.id,
status: 'draft'
}
}
end
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eq('New Article')
end
end
end
describe 'PUT /api/v1/accounts/:account_id/portals/:portal_slug/articles/:id' do
let(:article_params) do
{
article: {
title: 'Updated Article',
content: 'This is an updated article'
}
}
end
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
params: article_params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eq('Updated Article')
end
end
end
describe 'DELETE /api/v1/accounts/:account_id/portals/:portal_slug/articles/:id' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(Article.find_by(id: article.id)).to be_nil
end
end
end
end
@@ -0,0 +1,111 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise Categories API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id, config: { allowed_locales: %w[en es] }) }
let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, slug: 'category_slug', position: 1) }
# Create a custom role with knowledge_base_manage permission
let!(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
let!(:agent_with_role) { create(:user) }
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
# Ensure the account_user with custom role is created before tests run
before do
agent_with_role_account_user
end
describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug/categories' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug/categories/:id' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['name']).to eq('category')
end
end
end
describe 'POST /api/v1/accounts/:account_id/portals/:portal_slug/categories' do
let(:category_params) do
{
category: {
name: 'New Category',
slug: 'new-category',
locale: 'en',
description: 'This is a new category'
}
}
end
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['name']).to eq('New Category')
end
end
end
describe 'PUT /api/v1/accounts/:account_id/portals/:portal_slug/categories/:id' do
let(:category_params) do
{
category: {
name: 'Updated Category',
description: 'This is an updated category'
}
}
end
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
params: category_params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['name']).to eq('Updated Category')
end
end
end
describe 'DELETE /api/v1/accounts/:account_id/portals/:portal_slug/categories/:id' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
end
@@ -0,0 +1,90 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise Portal API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id) }
# Create a custom role with knowledge_base_manage permission
let!(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
# Create user without account
let!(:agent_with_role) { create(:user) }
# Then create account_user association with custom_role
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
# Ensure the account_user with custom role is created before tests run
before do
agent_with_role_account_user
end
describe 'GET /api/v1/accounts/:account_id/portals' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
get "/api/v1/accounts/#{account.id}/portals",
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['name']).to eq('test_portal')
end
end
end
describe 'POST /api/v1/accounts/:account_id/portals' do
let(:portal_params) do
{ portal: {
name: 'test_portal',
slug: 'test_kbase',
custom_domain: 'https://support.chatwoot.dev'
} }
end
context 'when it is an authenticated user' do
it 'restricts portal creation for agents with knowledge_base_manage permission' do
post "/api/v1/accounts/#{account.id}/portals",
params: portal_params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'PUT /api/v1/accounts/:account_id/portals/:portal_slug' do
let(:portal_params) do
{ portal: { name: 'updated_portal' } }
end
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
params: portal_params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['name']).to eq('updated_portal')
end
end
end
end
@@ -241,4 +241,99 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
end
end
end
describe 'POST /enterprise/api/v1/accounts/{account.id}/toggle_deletion' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
context 'when it is an agent' do
it 'returns unauthorized' do
post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when deployment environment is not cloud' do
before do
# Set deployment environment to something other than cloud
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'self_hosted')
end
it 'returns not found' do
post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion",
headers: admin.create_new_auth_token,
params: { action_type: 'delete' },
as: :json
expect(response).to have_http_status(:not_found)
expect(JSON.parse(response.body)['error']).to eq('Not found')
end
end
context 'when it is an admin' do
before do
# Create the installation config for cloud environment
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud')
end
it 'marks the account for deletion when action is delete' do
post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion",
headers: admin.create_new_auth_token,
params: { action_type: 'delete' },
as: :json
expect(response).to have_http_status(:ok)
expect(account.reload.custom_attributes['marked_for_deletion_at']).to be_present
expect(account.custom_attributes['marked_for_deletion_reason']).to eq('manual_deletion')
end
it 'unmarks the account for deletion when action is undelete' do
# First mark the account for deletion
account.update!(
custom_attributes: {
'marked_for_deletion_at' => 7.days.from_now.iso8601,
'marked_for_deletion_reason' => 'manual_deletion'
}
)
post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion",
headers: admin.create_new_auth_token,
params: { action_type: 'undelete' },
as: :json
expect(response).to have_http_status(:ok)
expect(account.reload.custom_attributes['marked_for_deletion_at']).to be_nil
expect(account.custom_attributes['marked_for_deletion_reason']).to be_nil
end
it 'returns error for invalid action' do
post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion",
headers: admin.create_new_auth_token,
params: { action_type: 'invalid' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)['error']).to include('Invalid action_type')
end
it 'returns error when action parameter is missing' do
post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)['error']).to include('Invalid action_type')
end
end
end
end
end
@@ -0,0 +1,67 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise Reports API', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
# Create a custom role with report_manage permission
let!(:custom_role) { create(:custom_role, account: account, permissions: ['report_manage']) }
let!(:agent_with_role) { create(:user) }
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
let(:default_timezone) { 'UTC' }
let(:start_of_today) { Time.current.in_time_zone(default_timezone).beginning_of_day.to_i }
let(:end_of_today) { Time.current.in_time_zone(default_timezone).end_of_day.to_i }
let(:params) { { timezone_offset: Time.zone.utc_offset } }
before do
agent_with_role_account_user
end
describe 'GET /api/v2/accounts/:account_id/reports' do
context 'when it is an authenticated user' do
let(:params) do
super().merge(
metric: 'conversations_count',
type: :account,
since: start_of_today.to_s,
until: end_of_today.to_s
)
end
it 'returns success for agents with report_manage permission' do
get "/api/v2/accounts/#{account.id}/reports",
params: params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
describe 'GET /api/v2/accounts/:account_id/reports/summary' do
context 'when it is an authenticated user' do
let(:params) do
super().merge(
type: :account,
since: start_of_today.to_s,
until: end_of_today.to_s
)
end
it 'returns success for agents with report_manage permission' do
get "/api/v2/accounts/#{account.id}/reports/summary",
params: params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
end
+49
View File
@@ -221,4 +221,53 @@ RSpec.describe Account, type: :model do
end
end
end
describe 'account deletion' do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
describe '#mark_for_deletion' do
it 'sets the marked_for_deletion_at and marked_for_deletion_reason attributes' do
expect do
account.mark_for_deletion('test_reason')
end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(nil).to(be_present)
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('test_reason')
end
it 'sends a notification email to admin users' do
mailer = double
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
expect(mailer).to receive(:account_deletion).with(account, 'test_reason').and_return(mailer)
expect(mailer).to receive(:deliver_later)
account.mark_for_deletion('test_reason')
end
it 'returns true when successful' do
expect(account.mark_for_deletion).to be_truthy
end
end
describe '#unmark_for_deletion' do
before do
account.update!(
custom_attributes: {
'marked_for_deletion_at' => 7.days.from_now.iso8601,
'marked_for_deletion_reason' => 'test_reason'
}
)
end
it 'removes the marked_for_deletion_at and marked_for_deletion_reason attributes' do
expect do
account.unmark_for_deletion
end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(be_present).to(nil)
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from('test_reason').to(nil)
end
it 'returns true when successful' do
expect(account.unmark_for_deletion).to be_truthy
end
end
end
end
@@ -0,0 +1,28 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise::ArticlePolicy', type: :policy do
subject(:article_policy) { ArticlePolicy }
let(:account) { create(:account) }
let(:agent) { create(:user, account: account) } # Needed for author
let(:portal) { create(:portal, account: account) }
let(:article) { create(:article, account: account, portal: portal, author: agent) }
# Create a custom role with knowledge_base_manage permission
let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
let(:agent_with_role) { create(:user) } # Create without account
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
let(:agent_with_role_context) do
{ user: agent_with_role, account: account, account_user: agent_with_role_account_user }
end
permissions :index?, :update?, :show?, :edit?, :create?, :destroy?, :reorder? do
context 'when agent with knowledge_base_manage permission' do
it { expect(article_policy).to permit(agent_with_role_context, article) }
end
end
end
@@ -0,0 +1,27 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise::CategoryPolicy', type: :policy do
subject(:category_policy) { CategoryPolicy }
let(:account) { create(:account) }
let(:portal) { create(:portal, account: account) }
let(:category) { create(:category, account: account, portal: portal, slug: 'test-category') }
# Create a custom role with knowledge_base_manage permission
let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
let(:agent_with_role) { create(:user) } # Create without account
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
let(:agent_with_role_context) do
{ user: agent_with_role, account: account, account_user: agent_with_role_account_user }
end
permissions :index?, :update?, :show?, :edit?, :create?, :destroy? do
context 'when agent with knowledge_base_manage permission' do
it { expect(category_policy).to permit(agent_with_role_context, category) }
end
end
end
@@ -0,0 +1,32 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise::PortalPolicy', type: :policy do
subject(:portal_policy) { PortalPolicy }
let(:account) { create(:account) }
let(:portal) { create(:portal, account: account) }
# Create a custom role with knowledge_base_manage permission
let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
let(:agent_with_role) { create(:user) } # Create without account
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
let(:agent_with_role_context) do
{ user: agent_with_role, account: account, account_user: agent_with_role_account_user }
end
permissions :update?, :edit?, :logo? do
context 'when agent with knowledge_base_manage permission' do
it { expect(portal_policy).to permit(agent_with_role_context, portal) }
end
end
permissions :create?, :destroy? do
context 'when agent with knowledge_base_manage permission' do
it { expect(portal_policy).not_to permit(agent_with_role_context, portal) }
end
end
end
@@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise::ReportPolicy', type: :policy do
subject(:report_policy) { ReportPolicy }
let(:account) { create(:account) }
let(:report) { :report }
# Create a custom role with report_manage permission
let(:custom_role) { create(:custom_role, account: account, permissions: ['report_manage']) }
let(:agent_with_role) { create(:user) } # Create without account
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
let(:agent_with_role_context) do
{ user: agent_with_role, account: account, account_user: agent_with_role_account_user }
end
permissions :view? do
context 'when agent with report_manage permission' do
it { expect(report_policy).to permit(agent_with_role_context, report) }
end
end
end
@@ -6,6 +6,21 @@ FactoryBot.define do
expires_at { 60.days.from_now }
updated_at { 25.hours.ago }
before :create do |channel|
WebMock::API.stub_request(:post, "https://graph.instagram.com/v22.0/#{channel.instagram_id}/subscribed_apps")
.with(query: {
access_token: channel.access_token,
subscribed_fields: %w[messages message_reactions messaging_seen]
})
.to_return(status: 200, body: '', headers: {})
WebMock::API.stub_request(:delete, "https://graph.instagram.com/v22.0/#{channel.instagram_id}/subscribed_apps")
.with(query: {
access_token: channel.access_token
})
.to_return(status: 200, body: '', headers: {})
end
after(:create) do |channel|
create(:inbox, channel: channel, account: channel.account)
end
-6
View File
@@ -1,6 +0,0 @@
FactoryBot.define do
factory :portal_member do
portal
user
end
end
@@ -0,0 +1,98 @@
require 'rails_helper'
RSpec.describe Instagram::IntegrationHelper do
include described_class
describe '#generate_instagram_token' do
let(:account_id) { 1 }
let(:client_secret) { 'test_secret' }
let(:current_time) { Time.current }
before do
allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_SECRET', nil).and_return(client_secret)
allow(Time).to receive(:current).and_return(current_time)
end
it 'generates a valid JWT token with correct payload' do
token = generate_instagram_token(account_id)
decoded_token = JWT.decode(token, client_secret, true, algorithm: 'HS256').first
expect(decoded_token['sub']).to eq(account_id)
expect(decoded_token['iat']).to eq(current_time.to_i)
end
context 'when client secret is not configured' do
let(:client_secret) { nil }
it 'returns nil' do
expect(generate_instagram_token(account_id)).to be_nil
end
end
context 'when an error occurs' do
before do
allow(JWT).to receive(:encode).and_raise(StandardError.new('Test error'))
end
it 'logs the error and returns nil' do
expect(Rails.logger).to receive(:error).with('Failed to generate Instagram token: Test error')
expect(generate_instagram_token(account_id)).to be_nil
end
end
end
describe '#token_payload' do
let(:account_id) { 1 }
let(:current_time) { Time.current }
before do
allow(Time).to receive(:current).and_return(current_time)
end
it 'returns a hash with the correct structure' do
payload = token_payload(account_id)
expect(payload).to be_a(Hash)
expect(payload[:sub]).to eq(account_id)
expect(payload[:iat]).to eq(current_time.to_i)
end
end
describe '#verify_instagram_token' do
let(:account_id) { 1 }
let(:client_secret) { 'test_secret' }
let(:valid_token) do
JWT.encode({ sub: account_id, iat: Time.current.to_i }, client_secret, 'HS256')
end
before do
allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_SECRET', nil).and_return(client_secret)
end
it 'successfully verifies and returns account_id from valid token' do
expect(verify_instagram_token(valid_token)).to eq(account_id)
end
context 'when token is blank' do
it 'returns nil' do
expect(verify_instagram_token('')).to be_nil
expect(verify_instagram_token(nil)).to be_nil
end
end
context 'when client secret is not configured' do
let(:client_secret) { nil }
it 'returns nil' do
expect(verify_instagram_token(valid_token)).to be_nil
end
end
context 'when token is invalid' do
it 'logs the error and returns nil' do
expect(Rails.logger).to receive(:error).with(/Unexpected error verifying Instagram token:/)
expect(verify_instagram_token('invalid_token')).to be_nil
end
end
end
end
@@ -60,7 +60,7 @@ RSpec.describe Account::ContactsExportJob do
it 'generates CSV file and attach to account' do
mailer = double
allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).with(account: account).and_return(mailer)
allow(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
allow(mailer).to receive(:contact_export_complete)
described_class.perform_now(account.id, user.id, [], {})
@@ -0,0 +1,116 @@
require 'rails_helper'
require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb'
RSpec.describe AdministratorNotifications::AccountNotificationMailer do
include_context 'with smtp config'
let!(:account) { create(:account) }
let!(:admin) { create(:user, account: account, role: :administrator) }
describe 'account_deletion' do
let(:reason) { 'manual_deletion' }
let(:mail) { described_class.with(account: account).account_deletion(account, reason) }
let(:deletion_date) { 7.days.from_now.iso8601 }
before do
account.update!(custom_attributes: {
'marked_for_deletion_at' => deletion_date,
'marked_for_deletion_reason' => reason
})
end
it 'renders the subject' do
expect(mail.subject).to eq('Your account has been marked for deletion')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
it 'includes the account name in the email body' do
expect(mail.body.encoded).to include(account.name)
end
it 'includes the deletion date in the email body' do
expect(mail.body.encoded).to include(deletion_date)
end
it 'includes a link to cancel the deletion' do
expect(mail.body.encoded).to include('Cancel Account Deletion')
end
context 'when reason is manual_deletion' do
it 'includes the administrator message' do
expect(mail.body.encoded).to include('This action was requested by one of the administrators of your account')
end
end
context 'when reason is not manual_deletion' do
let(:reason) { 'inactivity' }
it 'includes the reason directly' do
expect(mail.body.encoded).to include('Reason for deletion: inactivity')
end
end
end
describe 'contact_import_complete' do
let!(:data_import) { build(:data_import, total_records: 10, processed_records: 8) }
let(:mail) { described_class.with(account: account).contact_import_complete(data_import).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Contact Import Completed')
end
it 'renders the processed records' do
expect(mail.body.encoded).to include('Number of records imported: 8')
expect(mail.body.encoded).to include('Number of records failed: 2')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
end
describe 'contact_import_failed' do
let(:mail) { described_class.with(account: account).contact_import_failed.deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Contact Import Failed')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
end
describe 'contact_export_complete' do
let!(:file_url) { 'http://test.com/test' }
let(:mail) { described_class.with(account: account).contact_export_complete(file_url, admin.email).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("Your contact's export file is available to download.")
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
end
describe 'automation_rule_disabled' do
let(:rule) { instance_double(AutomationRule, name: 'Test Rule') }
let(:mail) { described_class.with(account: account).automation_rule_disabled(rule).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Automation rule disabled due to validation errors.')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
it 'includes the rule name in the email body' do
expect(mail.body.encoded).to include('Test Rule')
end
end
end
@@ -0,0 +1,75 @@
require 'rails_helper'
RSpec.describe AdministratorNotifications::BaseMailer do
let!(:account) { create(:account) }
let!(:admin1) { create(:user, account: account, role: :administrator) }
let!(:admin2) { create(:user, account: account, role: :administrator) }
let!(:agent) { create(:user, account: account, role: :agent) }
let(:mailer) { described_class.new }
let!(:inbox) { create(:inbox, account: account) }
before do
Current.account = account
end
describe 'admin_emails' do
it 'returns emails of all administrators' do
# Call the private method
admin_emails = mailer.send(:admin_emails)
expect(admin_emails).to include(admin1.email)
expect(admin_emails).to include(admin2.email)
expect(admin_emails).not_to include(agent.email)
end
end
describe 'helper methods' do
it 'generates correct inbox URL' do
url = mailer.inbox_url(inbox)
expected_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/inboxes/#{inbox.id}"
expect(url).to eq(expected_url)
end
it 'generates correct settings URL' do
url = mailer.settings_url('automation/list')
expected_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/automation/list"
expect(url).to eq(expected_url)
end
end
describe 'send_notification' do
before do
allow(mailer).to receive(:smtp_config_set_or_development?).and_return(true)
end
it 'sends email with correct parameters' do
subject = 'Test Subject'
action_url = 'https://example.com'
meta = { 'key' => 'value' }
# Mock the send_mail_with_liquid method
expect(mailer).to receive(:send_mail_with_liquid).with(
to: [admin1.email, admin2.email],
subject: subject
).and_return(true)
mailer.send_notification(subject, action_url: action_url, meta: meta)
# Check that instance variables are set correctly
expect(mailer.instance_variable_get(:@action_url)).to eq(action_url)
expect(mailer.instance_variable_get(:@meta)).to eq(meta)
end
it 'uses provided email addresses when specified' do
subject = 'Test Subject'
custom_email = 'custom@example.com'
expect(mailer).to receive(:send_mail_with_liquid).with(
to: custom_email,
subject: subject
).and_return(true)
mailer.send_notification(subject, to: custom_email)
end
end
end
@@ -1,45 +1,15 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb'
RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
include_context 'with smtp config'
let(:class_instance) { described_class.new }
let!(:account) { create(:account) }
let!(:administrator) { create(:user, :administrator, email: 'agent1@example.com', account: account) }
before do
allow(described_class).to receive(:new).and_return(class_instance)
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
end
describe 'slack_disconnect' do
let(:mail) { described_class.with(account: account).slack_disconnect.deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Your Slack integration has expired')
end
it 'renders the receiver email' do
expect(mail.to).to eq([administrator.email])
end
end
describe 'dialogflow disconnect' do
let(:mail) { described_class.with(account: account).dialogflow_disconnect.deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Your Dialogflow integration was disconnected')
end
it 'renders the content' do
expect(mail.body).to include('Your Dialogflow integration was disconnected because of permission issues.')
end
it 'renders the receiver email' do
expect(mail.to).to eq([administrator.email])
end
end
describe 'facebook_disconnect' do
before do
stub_request(:post, /graph.facebook.com/)
@@ -47,14 +17,17 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
let!(:facebook_channel) { create(:channel_facebook_page, account: account) }
let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: account) }
let(:mail) { described_class.with(account: account).facebook_disconnect(facebook_inbox).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Your Facebook page connection has expired')
end
context 'when sending the actual email' do
let(:mail) { described_class.with(account: account).facebook_disconnect(facebook_inbox).deliver_now }
it 'renders the receiver email' do
expect(mail.to).to eq([administrator.email])
it 'renders the subject' do
expect(mail.subject).to eq('Your Facebook page connection has expired')
end
it 'renders the receiver email' do
expect(mail.to).to eq([administrator.email])
end
end
end
@@ -72,30 +45,13 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
end
end
describe 'contact_import_complete' do
let!(:data_import) { build(:data_import, total_records: 10, processed_records: 10) }
let(:mail) { described_class.with(account: account).contact_import_complete(data_import).deliver_now }
describe 'instagram_disconnect' do
let!(:instagram_channel) { create(:channel_instagram, account: account) }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account) }
let(:mail) { described_class.with(account: account).instagram_disconnect(instagram_inbox).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Contact Import Completed')
end
it 'renders the processed records' do
expect(mail.body.encoded).to match('Number of records imported: 10')
expect(mail.body.encoded).to match('Number of records failed: 0')
end
it 'renders the receiver email' do
expect(mail.to).to eq([administrator.email])
end
end
describe 'contact_export_complete' do
let!(:file_url) { 'http://test.com/test' }
let(:mail) { described_class.with(account: account).contact_export_complete(file_url, administrator.email).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("Your contact's export file is available to download.")
expect(mail.subject).to eq('Your Instagram connection has expired')
end
it 'renders the receiver email' do
@@ -0,0 +1,41 @@
require 'rails_helper'
require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb'
RSpec.describe AdministratorNotifications::IntegrationsNotificationMailer do
include_context 'with smtp config'
let!(:account) { create(:account) }
let!(:administrator) { create(:user, :administrator, email: 'admin@example.com', account: account) }
describe 'slack_disconnect' do
let(:mail) { described_class.with(account: account).slack_disconnect.deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Your Slack integration has expired')
end
it 'renders the receiver email' do
expect(mail.to).to eq([administrator.email])
end
it 'includes reconnect instructions in the body' do
expect(mail.body.encoded).to include('To continue receiving messages on Slack, please delete the integration and connect your workspace again')
end
end
describe 'dialogflow_disconnect' do
let(:mail) { described_class.with(account: account).dialogflow_disconnect.deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Your Dialogflow integration was disconnected')
end
it 'renders the content' do
expect(mail.body.encoded).to include('Your Dialogflow integration was disconnected because of permission issues')
end
it 'renders the receiver email' do
expect(mail.to).to eq([administrator.email])
end
end
end
@@ -0,0 +1,11 @@
# frozen_string_literal: true
RSpec.shared_context 'with smtp config' do
before do
# We need to use allow_any_instance_of here because smtp_config_set_or_development?
# is defined in ApplicationMailer and needs to be stubbed for all mailer instances
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(ApplicationMailer).to receive(:smtp_config_set_or_development?).and_return(true)
# rubocop:enable RSpec/AnyInstance
end
end
+18
View File
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
RSpec.describe Channel::Instagram do
let(:channel) { create(:channel_instagram) }
@@ -14,4 +15,21 @@ RSpec.describe Channel::Instagram do
it 'has a valid name' do
expect(channel.name).to eq('Instagram')
end
describe 'concerns' do
it_behaves_like 'reauthorizable'
context 'when prompt_reauthorization!' do
it 'calls channel notifier mail for instagram' do
admin_mailer = double
mailer_double = double
expect(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(admin_mailer)
expect(admin_mailer).to receive(:instagram_disconnect).with(channel.inbox).and_return(mailer_double)
expect(mailer_double).to receive(:deliver_later)
channel.prompt_reauthorization!
end
end
end
end

Some files were not shown because too many files have changed in this diff Show More