diff --git a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb new file mode 100644 index 000000000..eace4411a --- /dev/null +++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index fe9a03ef5..6cfed161f 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -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 diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index af3655e59..6e2d0ff4c 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -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') diff --git a/app/controllers/concerns/instagram_concern.rb b/app/controllers/concerns/instagram_concern.rb new file mode 100644 index 000000000..f4dcfa010 --- /dev/null +++ b/app/controllers/concerns/instagram_concern.rb @@ -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 diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb new file mode 100644 index 000000000..4dc8ece1c --- /dev/null +++ b/app/controllers/instagram/callbacks_controller.rb @@ -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 diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 3e17a7369..550b6c893 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -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 diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index b658915ed..3d46334ca 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -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 diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index f7b04a167..0bf4e44ca 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -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 diff --git a/app/helpers/instagram/integration_helper.rb b/app/helpers/instagram/integration_helper.rb new file mode 100644 index 000000000..8ba57bf95 --- /dev/null +++ b/app/helpers/instagram/integration_helper.rb @@ -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 diff --git a/app/javascript/dashboard/api/channel/instagramClient.js b/app/javascript/dashboard/api/channel/instagramClient.js new file mode 100644 index 000000000..51ae26448 --- /dev/null +++ b/app/javascript/dashboard/api/channel/instagramClient.js @@ -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(); diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index bb95335ad..3f12dc007 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -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(); diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js index 4fb1bd0ee..9c65b0b67 100644 --- a/app/javascript/dashboard/api/enterprise/specs/account.spec.js +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -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' } + ); + }); }); }); diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index 26249e881..cf59532e0 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -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() { diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index ae798d8d9..865122386 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -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 { { case INBOX_TYPES.LINE: return 'brand-line'; + case INBOX_TYPES.INSTAGRAM: + return 'brand-instagram'; + default: return 'chat'; } diff --git a/app/javascript/dashboard/i18n/locale/en/generalSettings.json b/app/javascript/dashboard/i18n/locale/en/generalSettings.json index 4e28e0b2f..cfda6c7da 100644 --- a/app/javascript/dashboard/i18n/locale/en/generalSettings.json +++ b/app/javascript/dashboard/i18n/locale/en/generalSettings.json @@ -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": { diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 20f5ebed3..5e26a34c0 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -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" } } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue index a2a1f28fb..7afb3cf00 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue @@ -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')); + } + }, }, }; @@ -175,7 +259,7 @@ export default { -
+
+
+
+
+

+ {{ $t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.TITLE') }} +

+

+ {{ $t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.NOTE') }} +

+
+
+
+
+

+ {{ + $t( + `GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_${markedForDeletionReason === 'manual_deletion' ? 'MANUAL' : 'INACTIVITY'}`, + { + deletionDate: formattedDeletionDate, + } + ) + }} +

+ +
+
+
+ +
+
+
+ +
{{ `v${globalConfig.appVersion}` }}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue index b34c50c7f..7ea58e3e5 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue @@ -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({ diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 8bd2f3e6f..845206d1c 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -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 { /> -
+
+
@@ -291,7 +293,7 @@ export default {
- +
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue new file mode 100644 index 000000000..ff0933e11 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue @@ -0,0 +1,129 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/instagram/Reauthorize.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/instagram/Reauthorize.vue new file mode 100644 index 000000000..003017d1d --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/instagram/Reauthorize.vue @@ -0,0 +1,37 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue index 91cc2282e..b7b0a2c1d 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue @@ -28,6 +28,7 @@ const i18nMap = { 'Channel::Telegram': 'TELEGRAM', 'Channel::Line': 'LINE', 'Channel::Api': 'API', + 'Channel::Instagram': 'INSTAGRAM', }; const twilioChannelName = () => { diff --git a/app/javascript/dashboard/store/modules/accounts.js b/app/javascript/dashboard/store/modules/accounts.js index 4ddd73aa0..5ea1490e3 100644 --- a/app/javascript/dashboard/store/modules/accounts.js +++ b/app/javascript/dashboard/store/modules/accounts.js @@ -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 { diff --git a/app/javascript/dashboard/store/modules/specs/account/actions.spec.js b/app/javascript/dashboard/store/modules/specs/account/actions.spec.js index 92f1328a5..57b4a2f80 100644 --- a/app/javascript/dashboard/store/modules/specs/account/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/account/actions.spec.js @@ -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 }], + ]); + }); + }); }); diff --git a/app/javascript/shared/constants/links.js b/app/javascript/shared/constants/links.js index b2a27b036..b9732c0a2 100644 --- a/app/javascript/shared/constants/links.js +++ b/app/javascript/shared/constants/links.js @@ -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.', }; diff --git a/app/javascript/shared/constants/messages.js b/app/javascript/shared/constants/messages.js index 9451f83d3..b827216a5 100644 --- a/app/javascript/shared/constants/messages.js +++ b/app/javascript/shared/constants/messages.js @@ -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', diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js index 82ee9db9e..94f5997f7 100644 --- a/app/javascript/shared/mixins/inboxMixin.js +++ b/app/javascript/shared/mixins/inboxMixin.js @@ -121,6 +121,9 @@ export default { this.isATwilioWhatsAppChannel ); }, + isAInstagramChannel() { + return this.channelType === INBOX_TYPES.INSTAGRAM; + }, }, methods: { inboxHasFeature(feature) { diff --git a/app/jobs/account/contacts_export_job.rb b/app/jobs/account/contacts_export_job.rb index 778542b3c..33edcaa34 100644 --- a/app/jobs/account/contacts_export_job.rb +++ b/app/jobs/account/contacts_export_job.rb @@ -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 diff --git a/app/jobs/data_import_job.rb b/app/jobs/data_import_job.rb index 9703d2e50..6146336fa 100644 --- a/app/jobs/data_import_job.rb +++ b/app/jobs/data_import_job.rb @@ -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 diff --git a/app/mailers/administrator_notifications/account_notification_mailer.rb b/app/mailers/administrator_notifications/account_notification_mailer.rb new file mode 100644 index 000000000..8837e4f8c --- /dev/null +++ b/app/mailers/administrator_notifications/account_notification_mailer.rb @@ -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 diff --git a/app/mailers/administrator_notifications/base_mailer.rb b/app/mailers/administrator_notifications/base_mailer.rb new file mode 100644 index 000000000..2cedddbdb --- /dev/null +++ b/app/mailers/administrator_notifications/base_mailer.rb @@ -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 diff --git a/app/mailers/administrator_notifications/channel_notifications_mailer.rb b/app/mailers/administrator_notifications/channel_notifications_mailer.rb index dc4e6d7fe..05508a4ce 100644 --- a/app/mailers/administrator_notifications/channel_notifications_mailer.rb +++ b/app/mailers/administrator_notifications/channel_notifications_mailer.rb @@ -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 diff --git a/app/mailers/administrator_notifications/integrations_notification_mailer.rb b/app/mailers/administrator_notifications/integrations_notification_mailer.rb new file mode 100644 index 000000000..05477eca1 --- /dev/null +++ b/app/mailers/administrator_notifications/integrations_notification_mailer.rb @@ -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 diff --git a/app/models/account.rb b/app/models/account.rb index eb95194c5..decfc9d2e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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') diff --git a/app/models/channel/instagram.rb b/app/models/channel/instagram.rb index fcfcb852e..092973fcd 100644 --- a/app/models/channel/instagram.rb +++ b/app/models/channel/instagram.rb @@ -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 diff --git a/app/models/concerns/reauthorizable.rb b/app/models/concerns/reauthorizable.rb index b6792ebf1..94bbed0d9 100644 --- a/app/models/concerns/reauthorizable.rb +++ b/app/models/concerns/reauthorizable.rb @@ -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 diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 508675858..20c8dc610 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -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? diff --git a/app/models/portal.rb b/app/models/portal.rb index cc64be1fd..cb87929f5 100644 --- a/app/models/portal.rb +++ b/app/models/portal.rb @@ -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 diff --git a/app/models/portal_member.rb b/app/models/portal_member.rb deleted file mode 100644 index e0e687ae1..000000000 --- a/app/models/portal_member.rb +++ /dev/null @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index faadb3271..5594b0ca6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index 5eb80c1ab..61e02ae77 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -26,4 +26,8 @@ class AccountPolicy < ApplicationPolicy def checkout? @account_user.administrator? end + + def toggle_deletion? + @account_user.administrator? + end end diff --git a/app/policies/article_policy.rb b/app/policies/article_policy.rb index e48442337..282a507be 100644 --- a/app/policies/article_policy.rb +++ b/app/policies/article_policy.rb @@ -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') diff --git a/app/policies/category_policy.rb b/app/policies/category_policy.rb index 3dca6b847..104022595 100644 --- a/app/policies/category_policy.rb +++ b/app/policies/category_policy.rb @@ -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') diff --git a/app/policies/portal_policy.rb b/app/policies/portal_policy.rb index c52b44cac..1e09c41f6 100644 --- a/app/policies/portal_policy.rb +++ b/app/policies/portal_policy.rb @@ -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') diff --git a/app/policies/report_policy.rb b/app/policies/report_policy.rb index a52607a67..a2701ffd2 100644 --- a/app/policies/report_policy.rb +++ b/app/policies/report_policy.rb @@ -4,4 +4,4 @@ class ReportPolicy < ApplicationPolicy end end -ReportPolicy.prepend_mod_with('Enterprise::ReportPolicy') +ReportPolicy.prepend_mod_with('ReportPolicy') diff --git a/app/services/instagram/refresh_oauth_token_service.rb b/app/services/instagram/refresh_oauth_token_service.rb new file mode 100644 index 000000000..087fbcfa2 --- /dev/null +++ b/app/services/instagram/refresh_oauth_token_service.rb @@ -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 diff --git a/app/views/api/v1/accounts/portals/_portal.json.jbuilder b/app/views/api/v1/accounts/portals/_portal.json.jbuilder index 199ab352f..5e267f60c 100644 --- a/app/views/api/v1/accounts/portals/_portal.json.jbuilder +++ b/app/views/api/v1/accounts/portals/_portal.json.jbuilder @@ -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) diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index 5e9d9048a..52a199167 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -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 diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 9cad3edc1..9527b0787 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -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) diff --git a/app/views/mailers/administrator_notifications/account_notification_mailer/account_deletion.liquid b/app/views/mailers/administrator_notifications/account_notification_mailer/account_deletion.liquid new file mode 100644 index 000000000..0873dbac1 --- /dev/null +++ b/app/views/mailers/administrator_notifications/account_notification_mailer/account_deletion.liquid @@ -0,0 +1,16 @@ +

Hello,

+ +

Your account {{ meta.account_name }} has been marked for deletion. The account will be permanently deleted on {{ meta.deletion_date }}.

+ +{% if meta.reason == 'manual_deletion' %} +

This action was requested by one of the administrators of your account.

+{% else %} +

Reason for deletion: {{ meta.reason }}

+{% endif %} + +

If this was done in error, you can cancel the deletion process by visiting your account settings.

+ +

Cancel Account Deletion

+ +

Thank you,
+Team Chatwoot

\ No newline at end of file diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/automation_rule_disabled.liquid b/app/views/mailers/administrator_notifications/account_notification_mailer/automation_rule_disabled.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/automation_rule_disabled.liquid rename to app/views/mailers/administrator_notifications/account_notification_mailer/automation_rule_disabled.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_export_complete.liquid b/app/views/mailers/administrator_notifications/account_notification_mailer/contact_export_complete.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_export_complete.liquid rename to app/views/mailers/administrator_notifications/account_notification_mailer/contact_export_complete.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_complete.liquid b/app/views/mailers/administrator_notifications/account_notification_mailer/contact_import_complete.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_complete.liquid rename to app/views/mailers/administrator_notifications/account_notification_mailer/contact_import_complete.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_failed.liquid b/app/views/mailers/administrator_notifications/account_notification_mailer/contact_import_failed.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_failed.liquid rename to app/views/mailers/administrator_notifications/account_notification_mailer/contact_import_failed.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/instagram_disconnect.liquid b/app/views/mailers/administrator_notifications/channel_notifications_mailer/instagram_disconnect.liquid new file mode 100644 index 000000000..d1d4e6345 --- /dev/null +++ b/app/views/mailers/administrator_notifications/channel_notifications_mailer/instagram_disconnect.liquid @@ -0,0 +1,8 @@ +

Hello,

+ +

Your Instagram Inbox Access has expired.

+

Please reconnect Instagram to continue receiving messages.

+ +

+Click here to re-connect. +

\ No newline at end of file diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/dialogflow_disconnect.liquid b/app/views/mailers/administrator_notifications/integrations_notification_mailer/dialogflow_disconnect.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/dialogflow_disconnect.liquid rename to app/views/mailers/administrator_notifications/integrations_notification_mailer/dialogflow_disconnect.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/slack_disconnect.liquid b/app/views/mailers/administrator_notifications/integrations_notification_mailer/slack_disconnect.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/slack_disconnect.liquid rename to app/views/mailers/administrator_notifications/integrations_notification_mailer/slack_disconnect.liquid diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb index 37f6a77ff..fb10c1035 100644 --- a/app/views/super_admin/application/_icons.html.erb +++ b/app/views/super_admin/application/_icons.html.erb @@ -151,8 +151,12 @@ - + + + + + - + \ No newline at end of file diff --git a/config/features.yml b/config/features.yml index 70d6c9fcf..59b9aa2ad 100644 --- a/config/features.yml +++ b/config/features.yml @@ -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 diff --git a/config/installation_config.yml b/config/installation_config.yml index 15b815496..1a891c420 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -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 ------- # diff --git a/config/routes.rb b/config/routes.rb index 5bc965337..46199db12 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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' diff --git a/db/migrate/20250402233933_remove_portal_members.rb b/db/migrate/20250402233933_remove_portal_members.rb new file mode 100644 index 000000000..f145f5288 --- /dev/null +++ b/db/migrate/20250402233933_remove_portal_members.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 1f7217cd8..e4706342e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index 86ec2fb55..70f1d177d 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -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 diff --git a/enterprise/app/controllers/enterprise/api/v2/accounts/reports_controller.rb b/enterprise/app/controllers/enterprise/api/v2/accounts/reports_controller.rb deleted file mode 100644 index b5e2a9efe..000000000 --- a/enterprise/app/controllers/enterprise/api/v2/accounts/reports_controller.rb +++ /dev/null @@ -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 diff --git a/enterprise/app/helpers/super_admin/features.yml b/enterprise/app/helpers/super_admin/features.yml index a54aaf6eb..26a47f0e9 100644 --- a/enterprise/app/helpers/super_admin/features.yml +++ b/enterprise/app/helpers/super_admin/features.yml @@ -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' diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb index a1b0d0449..37bffc5a6 100644 --- a/enterprise/app/models/enterprise/account.rb +++ b/enterprise/app/models/enterprise/account.rb @@ -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 diff --git a/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb new file mode 100644 index 000000000..ce03efa41 --- /dev/null +++ b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb @@ -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 diff --git a/enterprise/app/policies/enterprise/portal_policy.rb b/enterprise/app/policies/enterprise/portal_policy.rb index 45c3ea140..73e911efb 100644 --- a/enterprise/app/policies/enterprise/portal_policy.rb +++ b/enterprise/app/policies/enterprise/portal_policy.rb @@ -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 diff --git a/spec/controllers/api/v1/accounts/articles_controller_spec.rb b/spec/controllers/api/v1/accounts/articles_controller_spec.rb index 754629b7b..42fd062dc 100644 --- a/spec/controllers/api/v1/accounts/articles_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/articles_controller_spec.rb @@ -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 diff --git a/spec/controllers/api/v1/accounts/categories_controller_spec.rb b/spec/controllers/api/v1/accounts/categories_controller_spec.rb index 03e658aac..48cc001e3 100644 --- a/spec/controllers/api/v1/accounts/categories_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/categories_controller_spec.rb @@ -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) diff --git a/spec/controllers/api/v1/accounts/instagram/authorizations_controller_spec.rb b/spec/controllers/api/v1/accounts/instagram/authorizations_controller_spec.rb new file mode 100644 index 000000000..003ecc023 --- /dev/null +++ b/spec/controllers/api/v1/accounts/instagram/authorizations_controller_spec.rb @@ -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 diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb index 7da6bf52f..aeec9cab4 100644 --- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb @@ -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 diff --git a/spec/controllers/concerns/instagram_concern_spec.rb b/spec/controllers/concerns/instagram_concern_spec.rb new file mode 100644 index 000000000..6ab03a272 --- /dev/null +++ b/spec/controllers/concerns/instagram_concern_spec.rb @@ -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 diff --git a/spec/controllers/instagram/callbacks_controller_spec.rb b/spec/controllers/instagram/callbacks_controller_spec.rb new file mode 100644 index 000000000..64b90491a --- /dev/null +++ b/spec/controllers/instagram/callbacks_controller_spec.rb @@ -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 diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/articles_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/articles_controller_spec.rb new file mode 100644 index 000000000..43d6fc02d --- /dev/null +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/articles_controller_spec.rb @@ -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 diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/categories_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/categories_controller_spec.rb new file mode 100644 index 000000000..f83542743 --- /dev/null +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/categories_controller_spec.rb @@ -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 diff --git a/enterprise/spec/controllers/api/v1/accounts/contacts/conversations_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/contacts/conversations_controller_spec.rb similarity index 100% rename from enterprise/spec/controllers/api/v1/accounts/contacts/conversations_controller_spec.rb rename to spec/enterprise/controllers/enterprise/api/v1/accounts/contacts/conversations_controller_spec.rb diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb new file mode 100644 index 000000000..cb296494c --- /dev/null +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb @@ -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 diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb index ac26dc525..0bd917c06 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb @@ -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 diff --git a/spec/enterprise/controllers/enterprise/api/v2/accounts/reports_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v2/accounts/reports_controller_spec.rb new file mode 100644 index 000000000..ff05af909 --- /dev/null +++ b/spec/enterprise/controllers/enterprise/api/v2/accounts/reports_controller_spec.rb @@ -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 diff --git a/spec/enterprise/models/account_spec.rb b/spec/enterprise/models/account_spec.rb index e36a7217d..4d851d50e 100644 --- a/spec/enterprise/models/account_spec.rb +++ b/spec/enterprise/models/account_spec.rb @@ -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 diff --git a/spec/enterprise/policies/article_policy_spec.rb b/spec/enterprise/policies/article_policy_spec.rb new file mode 100644 index 000000000..2417ab9e2 --- /dev/null +++ b/spec/enterprise/policies/article_policy_spec.rb @@ -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 diff --git a/spec/enterprise/policies/category_policy_spec.rb b/spec/enterprise/policies/category_policy_spec.rb new file mode 100644 index 000000000..004428a2b --- /dev/null +++ b/spec/enterprise/policies/category_policy_spec.rb @@ -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 diff --git a/spec/enterprise/policies/portal_policy_spec.rb b/spec/enterprise/policies/portal_policy_spec.rb new file mode 100644 index 000000000..1e419b02c --- /dev/null +++ b/spec/enterprise/policies/portal_policy_spec.rb @@ -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 diff --git a/spec/enterprise/policies/report_policy_spec.rb b/spec/enterprise/policies/report_policy_spec.rb new file mode 100644 index 000000000..0d87c22d7 --- /dev/null +++ b/spec/enterprise/policies/report_policy_spec.rb @@ -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 diff --git a/enterprise/spec/services/enterprise/conversations/permission_filter_service_spec.rb b/spec/enterprise/services/enterprise/conversations/permission_filter_service_spec.rb similarity index 100% rename from enterprise/spec/services/enterprise/conversations/permission_filter_service_spec.rb rename to spec/enterprise/services/enterprise/conversations/permission_filter_service_spec.rb diff --git a/spec/factories/channel/channel_instagram.rb b/spec/factories/channel/channel_instagram.rb index 9a0d33bb5..6665e4558 100644 --- a/spec/factories/channel/channel_instagram.rb +++ b/spec/factories/channel/channel_instagram.rb @@ -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 diff --git a/spec/factories/portal_members.rb b/spec/factories/portal_members.rb deleted file mode 100644 index 9cab7b054..000000000 --- a/spec/factories/portal_members.rb +++ /dev/null @@ -1,6 +0,0 @@ -FactoryBot.define do - factory :portal_member do - portal - user - end -end diff --git a/spec/helpers/instagram/integration_helper_spec.rb b/spec/helpers/instagram/integration_helper_spec.rb new file mode 100644 index 000000000..7a8bb30a4 --- /dev/null +++ b/spec/helpers/instagram/integration_helper_spec.rb @@ -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 diff --git a/spec/jobs/account/contacts_export_job_spec.rb b/spec/jobs/account/contacts_export_job_spec.rb index 7c1858d0a..e6d3fda6a 100644 --- a/spec/jobs/account/contacts_export_job_spec.rb +++ b/spec/jobs/account/contacts_export_job_spec.rb @@ -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, [], {}) diff --git a/spec/mailers/administrator_notifications/account_notification_mailer_spec.rb b/spec/mailers/administrator_notifications/account_notification_mailer_spec.rb new file mode 100644 index 000000000..44df38e88 --- /dev/null +++ b/spec/mailers/administrator_notifications/account_notification_mailer_spec.rb @@ -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 diff --git a/spec/mailers/administrator_notifications/base_mailer_spec.rb b/spec/mailers/administrator_notifications/base_mailer_spec.rb new file mode 100644 index 000000000..619fef0a7 --- /dev/null +++ b/spec/mailers/administrator_notifications/base_mailer_spec.rb @@ -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 diff --git a/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb b/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb index 944475fb2..e5cd7327b 100644 --- a/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb +++ b/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb @@ -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 diff --git a/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb b/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb new file mode 100644 index 000000000..331d33d06 --- /dev/null +++ b/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb @@ -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 diff --git a/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb b/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb new file mode 100644 index 000000000..96d4dbb0d --- /dev/null +++ b/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb @@ -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 diff --git a/spec/models/channel/instagram_spec.rb b/spec/models/channel/instagram_spec.rb index 901fe392e..a3cea9e92 100644 --- a/spec/models/channel/instagram_spec.rb +++ b/spec/models/channel/instagram_spec.rb @@ -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 diff --git a/spec/models/concerns/reauthorizable_shared.rb b/spec/models/concerns/reauthorizable_shared.rb index 9efe232e8..558312e6c 100644 --- a/spec/models/concerns/reauthorizable_shared.rb +++ b/spec/models/concerns/reauthorizable_shared.rb @@ -2,9 +2,9 @@ require 'rails_helper' shared_examples_for 'reauthorizable' do let(:model) { described_class } # the class that includes the concern + let(:obj) { FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) } it 'authorization_error!' do - obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) expect(obj.authorization_error_count).to eq 0 obj.authorization_error! @@ -13,7 +13,6 @@ shared_examples_for 'reauthorizable' do end it 'prompts reauthorization when error threshold is passed' do - obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) expect(obj.reauthorization_required?).to be false obj.class::AUTHORIZATION_ERROR_THRESHOLD.times do @@ -23,25 +22,72 @@ shared_examples_for 'reauthorizable' do expect(obj.reauthorization_required?).to be true end - it 'prompt_reauthorization!' do - obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) - mailer = double - mailer_method = double - allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(mailer) - # allow mailer to receive any methods and return mailer - allow(mailer).to receive(:method_missing).and_return(mailer_method) - allow(mailer_method).to receive(:deliver_later) + # Helper methods to set up mailer mocks + def setup_automation_rule_mailer(_obj) + account_mailer = instance_double(AdministratorNotifications::AccountNotificationMailer) + automation_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + allow(AdministratorNotifications::AccountNotificationMailer).to receive(:with).and_return(account_mailer) + allow(account_mailer).to receive(:automation_rule_disabled).and_return(automation_mailer_response) + end - expect(obj.reauthorization_required?).to be false + def setup_integrations_hook_mailer(obj) + integrations_mailer = instance_double(AdministratorNotifications::IntegrationsNotificationMailer) + slack_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + dialogflow_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + allow(AdministratorNotifications::IntegrationsNotificationMailer).to receive(:with).and_return(integrations_mailer) + allow(integrations_mailer).to receive(:slack_disconnect).and_return(slack_mailer_response) + allow(integrations_mailer).to receive(:dialogflow_disconnect).and_return(dialogflow_mailer_response) - obj.prompt_reauthorization! - expect(obj.reauthorization_required?).to be true - expect(AdministratorNotifications::ChannelNotificationsMailer).to have_received(:with).with(account: obj.account) - expect(mailer_method).to have_received(:deliver_later) + # Allow the model to respond to slack? and dialogflow? methods + allow(obj).to receive(:slack?).and_return(true) + allow(obj).to receive(:dialogflow?).and_return(false) + end + + def setup_channel_mailer(_obj) + channel_mailer = instance_double(AdministratorNotifications::ChannelNotificationsMailer) + facebook_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + whatsapp_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + email_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + instagram_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(channel_mailer) + allow(channel_mailer).to receive(:facebook_disconnect).and_return(facebook_mailer_response) + allow(channel_mailer).to receive(:whatsapp_disconnect).and_return(whatsapp_mailer_response) + allow(channel_mailer).to receive(:email_disconnect).and_return(email_mailer_response) + allow(channel_mailer).to receive(:instagram_disconnect).and_return(instagram_mailer_response) + end + + describe 'prompt_reauthorization!' do + before do + # Setup mailer mocks based on model type + if model.to_s == 'AutomationRule' + setup_automation_rule_mailer(obj) + elsif model.to_s == 'Integrations::Hook' + setup_integrations_hook_mailer(obj) + else + setup_channel_mailer(obj) + end + end + + it 'sets reauthorization required flag' do + expect(obj.reauthorization_required?).to be false + obj.prompt_reauthorization! + expect(obj.reauthorization_required?).to be true + end + + it 'calls the correct mailer based on model type' do + obj.prompt_reauthorization! + + if model.to_s == 'AutomationRule' + expect(AdministratorNotifications::AccountNotificationMailer).to have_received(:with).with(account: obj.account) + elsif model.to_s == 'Integrations::Hook' + expect(AdministratorNotifications::IntegrationsNotificationMailer).to have_received(:with).with(account: obj.account) + else + expect(AdministratorNotifications::ChannelNotificationsMailer).to have_received(:with).with(account: obj.account) + end + end end it 'reauthorized!' do - obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) # setting up the object with the errors to validate its cleared on action obj.authorization_error! obj.prompt_reauthorization! diff --git a/spec/models/portal_member_spec.rb b/spec/models/portal_member_spec.rb deleted file mode 100644 index 646ff9c27..000000000 --- a/spec/models/portal_member_spec.rb +++ /dev/null @@ -1,8 +0,0 @@ -require 'rails_helper' - -RSpec.describe PortalMember do - describe 'associations' do - it { is_expected.to belong_to(:portal) } - it { is_expected.to belong_to(:user) } - end -end diff --git a/spec/models/portal_spec.rb b/spec/models/portal_spec.rb index 1b33c8d90..1a108bbd6 100644 --- a/spec/models/portal_spec.rb +++ b/spec/models/portal_spec.rb @@ -12,8 +12,6 @@ RSpec.describe Portal do it { is_expected.to have_many(:categories) } it { is_expected.to have_many(:folders) } it { is_expected.to have_many(:articles) } - it { is_expected.to have_many(:portal_members) } - it { is_expected.to have_many(:members) } it { is_expected.to have_many(:inboxes) } end diff --git a/spec/policies/article_policy_spec.rb b/spec/policies/article_policy_spec.rb new file mode 100644 index 000000000..2c1b5f9d1 --- /dev/null +++ b/spec/policies/article_policy_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe ArticlePolicy, type: :policy do + subject(:article_policy) { described_class } + + let(:account) { create(:account) } + let(:administrator) { create(:user, :administrator, account: account) } + let(:agent) { create(:user, account: account) } + let(:portal) { create(:portal, account: account) } + let(:article) { create(:article, account: account, portal: portal, author: administrator) } + + let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } } + let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } } + + permissions :index? do + context 'when administrator' do + it { expect(article_policy).to permit(administrator_context, article) } + end + + context 'when agent' do + it { expect(article_policy).to permit(agent_context, article) } + end + end + + permissions :update?, :show?, :edit?, :create?, :destroy?, :reorder? do + context 'when administrator' do + it { expect(article_policy).to permit(administrator_context, article) } + end + + context 'when agent' do + it { expect(article_policy).not_to permit(agent_context, article) } + end + end +end diff --git a/spec/policies/category_policy_spec.rb b/spec/policies/category_policy_spec.rb new file mode 100644 index 000000000..933348635 --- /dev/null +++ b/spec/policies/category_policy_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe CategoryPolicy, type: :policy do + subject(:category_policy) { described_class } + + let(:account) { create(:account) } + let(:administrator) { create(:user, :administrator, account: account) } + let(:agent) { create(:user, account: account) } + let(:portal) { create(:portal, account: account) } + let(:category) { create(:category, account: account, portal: portal, slug: 'test-category') } + + let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } } + let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } } + + permissions :index? do + context 'when administrator' do + it { expect(category_policy).to permit(administrator_context, category) } + end + + context 'when agent' do + it { expect(category_policy).to permit(agent_context, category) } + end + end + + permissions :update?, :show?, :edit?, :create?, :destroy? do + context 'when administrator' do + it { expect(category_policy).to permit(administrator_context, category) } + end + + context 'when agent' do + it { expect(category_policy).not_to permit(agent_context, category) } + end + end +end diff --git a/spec/policies/portal_policy_spec.rb b/spec/policies/portal_policy_spec.rb new file mode 100644 index 000000000..061e8b97e --- /dev/null +++ b/spec/policies/portal_policy_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PortalPolicy, type: :policy do + subject(:portal_policy) { described_class } + + let(:account) { create(:account) } + let(:administrator) { create(:user, :administrator, account: account) } + let(:agent) { create(:user, account: account) } + let(:portal) { create(:portal, account: account) } + + let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } } + let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } } + + permissions :index?, :show? do + context 'when administrator' do + it { expect(portal_policy).to permit(administrator_context, portal) } + end + + context 'when agent' do + it { expect(portal_policy).to permit(agent_context, portal) } + end + end + + permissions :update?, :edit?, :create?, :destroy?, :logo? do + context 'when administrator' do + it { expect(portal_policy).to permit(administrator_context, portal) } + end + + context 'when agent' do + it { expect(portal_policy).not_to permit(agent_context, portal) } + end + end +end diff --git a/spec/policies/report_policy_spec.rb b/spec/policies/report_policy_spec.rb new file mode 100644 index 000000000..fde467bd8 --- /dev/null +++ b/spec/policies/report_policy_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ReportPolicy, type: :policy do + subject(:report_policy) { described_class } + + let(:account) { create(:account) } + let(:administrator) { create(:user, :administrator, account: account) } + let(:agent) { create(:user, account: account) } + let(:report) { :report } + + let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } } + let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } } + + permissions :view? do + context 'when administrator' do + it { expect(report_policy).to permit(administrator_context, report) } + end + + context 'when agent' do + it { expect(report_policy).not_to permit(agent_context, report) } + end + end +end diff --git a/spec/services/instagram/refresh_oauth_token_service_spec.rb b/spec/services/instagram/refresh_oauth_token_service_spec.rb new file mode 100644 index 000000000..007159a88 --- /dev/null +++ b/spec/services/instagram/refresh_oauth_token_service_spec.rb @@ -0,0 +1,127 @@ +require 'rails_helper' + +RSpec.describe Instagram::RefreshOauthTokenService do + let(:account) { create(:account) } + let(:refresh_response) do + { + 'access_token' => 'new_refreshed_token', + 'expires_in' => 5_184_000 # 60 days in seconds + } + end + let(:fixed_token) { 'c061d0c51973a8fcab2ecec86f6aa41718414a10070967a5e9a58f49bf8a798e' } + let(:instagram_channel) do + create(:channel_instagram, + account: account, + access_token: fixed_token, + expires_at: 20.days.from_now) # Set default expiry + end + let(:service) { described_class.new(channel: instagram_channel) } + + before do + stub_request(:get, 'https://graph.instagram.com/refresh_access_token') + .with( + query: { + 'access_token' => fixed_token, + 'grant_type' => 'ig_refresh_token' + }, + headers: { + 'Accept' => 'application/json', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Ruby' + } + ) + .to_return(status: 200, body: refresh_response.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + describe '#access_token' do + context 'when token is valid and not eligible for refresh' do + before do + instagram_channel.update!( + updated_at: 12.hours.ago # Less than 24 hours old + ) + end + + it 'returns existing token without refresh' do + expect(service).not_to receive(:refresh_long_lived_token) + expect(service.access_token).to eq(fixed_token) + end + end + + context 'when token is eligible for refresh' do + before do + instagram_channel.update!( + expires_at: 5.days.from_now, # Within 10 days window + updated_at: 25.hours.ago # More than 24 hours old + ) + end + + it 'refreshes the token and updates channel' do + expect(service.access_token).to eq('new_refreshed_token') + instagram_channel.reload + expect(instagram_channel.access_token).to eq('new_refreshed_token') + expect(instagram_channel.expires_at).to be_within(1.second).of(5_184_000.seconds.from_now) + end + end + end + + describe 'private methods' do + describe '#token_valid?' do + # For the expires_at null test, we need to modify the validation or use a different approach + context 'when expires_at is blank' do + it 'returns false' do + allow(instagram_channel).to receive(:expires_at).and_return(nil) + expect(service.send(:token_valid?)).to be false + end + end + + context 'when token is expired' do + it 'returns false' do + allow(instagram_channel).to receive(:expires_at).and_return(1.hour.ago) + expect(service.send(:token_valid?)).to be false + end + end + + context 'when token is valid' do + it 'returns true' do + allow(instagram_channel).to receive(:expires_at).and_return(1.day.from_now) + expect(service.send(:token_valid?)).to be true + end + end + end + + describe '#token_eligible_for_refresh?' do + context 'when token is too new' do + before do + allow(instagram_channel).to receive(:updated_at).and_return(12.hours.ago) + allow(instagram_channel).to receive(:expires_at).and_return(5.days.from_now) + end + + it 'returns false' do + expect(service.send(:token_eligible_for_refresh?)).to be false + end + end + + context 'when token is not approaching expiry' do + before do + allow(instagram_channel).to receive(:updated_at).and_return(25.hours.ago) + allow(instagram_channel).to receive(:expires_at).and_return(20.days.from_now) + end + + it 'returns false' do + expect(service.send(:token_eligible_for_refresh?)).to be false + end + end + + context 'when token is expired' do + before do + allow(instagram_channel).to receive(:updated_at).and_return(25.hours.ago) + allow(instagram_channel).to receive(:expires_at).and_return(1.hour.ago) + end + + it 'returns false' do + expect(service.send(:token_eligible_for_refresh?)).to be false + end + end + end + end +end