feat(whatsapp): add webhook registration and status endpoints (#13551)
## Description Adds webhook configuration management for WhatsApp Cloud API channels, allowing administrators to check webhook status and register webhooks directly from Chatwoot without accessing Meta Business Manager. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## Screenshots <img width="1130" height="676" alt="Screenshot 2026-03-05 at 7 04 18 PM" src="https://github.com/user-attachments/assets/f5dcd9dd-8827-42c5-a52b-1024012703c2" /> <img width="1101" height="651" alt="Screenshot 2026-03-05 at 7 04 29 PM" src="https://github.com/user-attachments/assets/e0bd59f9-2a90-4f24-87c0-b79f21e721ee" /> ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
committed by
GitHub
parent
28bf9fa5f9
commit
a452ce9e84
@@ -0,0 +1,55 @@
|
|||||||
|
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
skip_before_action :check_authorization, only: [:health, :register_webhook]
|
||||||
|
before_action :check_admin_authorization?, only: [:register_webhook]
|
||||||
|
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_templates
|
||||||
|
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||||
|
|
||||||
|
trigger_template_sync
|
||||||
|
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||||
|
rescue StandardError => e
|
||||||
|
render status: :internal_server_error, json: { error: e.message }
|
||||||
|
end
|
||||||
|
|
||||||
|
def health
|
||||||
|
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||||
|
render json: health_data
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def register_webhook
|
||||||
|
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
|
||||||
|
|
||||||
|
render json: { message: 'Webhook registered successfully' }, status: :ok
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_whatsapp_cloud_channel
|
||||||
|
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||||
|
|
||||||
|
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
def whatsapp_channel?
|
||||||
|
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def trigger_template_sync
|
||||||
|
if @inbox.whatsapp?
|
||||||
|
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||||
|
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||||
|
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,8 +4,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||||
before_action :validate_limit, only: [:create]
|
before_action :validate_limit, only: [:create]
|
||||||
# we are already handling the authorization in fetch inbox
|
# we are already handling the authorization in fetch inbox
|
||||||
before_action :check_authorization, except: [:show, :health]
|
before_action :check_authorization, except: [:show]
|
||||||
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
|
||||||
|
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||||
@@ -70,23 +71,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_templates
|
|
||||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
|
||||||
|
|
||||||
trigger_template_sync
|
|
||||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
|
||||||
rescue StandardError => e
|
|
||||||
render status: :internal_server_error, json: { error: e.message }
|
|
||||||
end
|
|
||||||
|
|
||||||
def health
|
|
||||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
|
||||||
render json: health_data
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
|
||||||
render json: { error: e.message }, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def fetch_inbox
|
def fetch_inbox
|
||||||
@@ -98,12 +82,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_whatsapp_cloud_channel
|
|
||||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
|
||||||
|
|
||||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_channel
|
def create_channel
|
||||||
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||||
|
|
||||||
@@ -200,18 +178,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
def get_channel_attributes(channel_type)
|
def get_channel_attributes(channel_type)
|
||||||
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||||
end
|
end
|
||||||
|
|
||||||
def whatsapp_channel?
|
|
||||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
|
||||||
end
|
|
||||||
|
|
||||||
def trigger_template_sync
|
|
||||||
if @inbox.whatsapp?
|
|
||||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
|
||||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
|
||||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient {
|
|||||||
getHealthStatus(inboxId) {
|
getHealthStatus(inboxId) {
|
||||||
return axios.get(`${this.url}/${inboxId}/health`);
|
return axios.get(`${this.url}/${inboxId}/health`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerWebhook(inboxId) {
|
||||||
|
return axios.post(`${this.url}/${inboxId}/register_webhook`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new InboxHealthAPI();
|
export default new InboxHealthAPI();
|
||||||
|
|||||||
@@ -685,6 +685,16 @@
|
|||||||
"SANDBOX": "Sandbox",
|
"SANDBOX": "Sandbox",
|
||||||
"LIVE": "Live"
|
"LIVE": "Live"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"WEBHOOK": {
|
||||||
|
"TITLE": "Webhook Configuration",
|
||||||
|
"DESCRIPTION": "Webhook URL is required for your WhatsApp Business Account to receive messages from customers",
|
||||||
|
"ACTION_REQUIRED": "Webhook not configured",
|
||||||
|
"REGISTER_BUTTON": "Register Webhook",
|
||||||
|
"REGISTER_SUCCESS": "Webhook registered successfully",
|
||||||
|
"REGISTER_ERROR": "Failed to register webhook. Please try again.",
|
||||||
|
"CONFIGURED_SUCCESS": "Webhook configured successfully",
|
||||||
|
"URL_MISMATCH": "Webhook URL mismatch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SETTINGS": "Settings",
|
"SETTINGS": "Settings",
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export default {
|
|||||||
healthData: null,
|
healthData: null,
|
||||||
isLoadingHealth: false,
|
isLoadingHealth: false,
|
||||||
healthError: null,
|
healthError: null,
|
||||||
|
isRegisteringWebhook: false,
|
||||||
widgetBubblePosition: 'right',
|
widgetBubblePosition: 'right',
|
||||||
widgetBubbleType: 'standard',
|
widgetBubbleType: 'standard',
|
||||||
widgetBubbleLauncherTitle: '',
|
widgetBubbleLauncherTitle: '',
|
||||||
@@ -424,6 +425,23 @@ export default {
|
|||||||
this.isLoadingHealth = false;
|
this.isLoadingHealth = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async registerWebhook() {
|
||||||
|
if (!this.inbox) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.isRegisteringWebhook = true;
|
||||||
|
await InboxHealthAPI.registerWebhook(this.inbox.id);
|
||||||
|
useAlert(this.$t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_SUCCESS'));
|
||||||
|
await this.fetchHealthData();
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(
|
||||||
|
error.message ||
|
||||||
|
this.$t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_ERROR')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.isRegisteringWebhook = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
handleFeatureFlag(e) {
|
handleFeatureFlag(e) {
|
||||||
this.selectedFeatureFlags = this.toggleInput(
|
this.selectedFeatureFlags = this.toggleInput(
|
||||||
this.selectedFeatureFlags,
|
this.selectedFeatureFlags,
|
||||||
@@ -1162,7 +1180,11 @@ export default {
|
|||||||
<BotConfiguration :inbox="inbox" />
|
<BotConfiguration :inbox="inbox" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedTabKey === 'whatsapp-health'">
|
<div v-if="selectedTabKey === 'whatsapp-health'">
|
||||||
<AccountHealth :health-data="healthData" />
|
<AccountHealth
|
||||||
|
:health-data="healthData"
|
||||||
|
:is-registering-webhook="isRegisteringWebhook"
|
||||||
|
@register-webhook="registerWebhook"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
isRegisteringWebhook: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['registerWebhook']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const QUALITY_COLORS = {
|
const QUALITY_COLORS = {
|
||||||
@@ -133,6 +139,28 @@ const formatModeDisplay = mode =>
|
|||||||
const getModeStatusTextColor = mode => MODE_COLORS[mode] || 'text-n-slate-12';
|
const getModeStatusTextColor = mode => MODE_COLORS[mode] || 'text-n-slate-12';
|
||||||
|
|
||||||
const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
|
const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
|
||||||
|
|
||||||
|
const showWebhookSection = computed(
|
||||||
|
() => props.healthData?.webhook_configuration !== undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const webhookUrl = computed(
|
||||||
|
() =>
|
||||||
|
props.healthData?.webhook_configuration?.whatsapp_business_account ||
|
||||||
|
props.healthData?.webhook_configuration?.application
|
||||||
|
);
|
||||||
|
|
||||||
|
const webhookConfigured = computed(() => !!webhookUrl.value);
|
||||||
|
|
||||||
|
const webhookUrlMismatch = computed(
|
||||||
|
() =>
|
||||||
|
webhookConfigured.value &&
|
||||||
|
webhookUrl.value !== props.healthData?.expected_webhook_url
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRegisterWebhook = () => {
|
||||||
|
emit('registerWebhook');
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -211,6 +239,55 @@ const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
|
|||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook configuration card -->
|
||||||
|
<div
|
||||||
|
v-if="showWebhookSection"
|
||||||
|
class="flex flex-col gap-2 p-4 rounded-lg border border-n-weak bg-n-solid-1"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<span class="text-body-main font-medium text-n-slate-11">
|
||||||
|
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.TITLE') }}
|
||||||
|
</span>
|
||||||
|
<Icon
|
||||||
|
v-tooltip.top="t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.DESCRIPTION')"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
class="flex-shrink-0 w-4 h-4 cursor-help text-n-slate-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<span
|
||||||
|
v-if="webhookConfigured && !webhookUrlMismatch"
|
||||||
|
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-teal-11"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-check-circle" class="w-3.5 h-3.5" />
|
||||||
|
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.CONFIGURED_SUCCESS') }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-amber-11"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-alert-triangle" class="w-3.5 h-3.5" />
|
||||||
|
{{
|
||||||
|
webhookUrlMismatch
|
||||||
|
? t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.URL_MISMATCH')
|
||||||
|
: t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.ACTION_REQUIRED')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<ButtonV4
|
||||||
|
v-if="!webhookConfigured || webhookUrlMismatch"
|
||||||
|
sm
|
||||||
|
solid
|
||||||
|
blue
|
||||||
|
:loading="isRegisteringWebhook"
|
||||||
|
:disabled="isRegisteringWebhook"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
@click="handleRegisterWebhook"
|
||||||
|
>
|
||||||
|
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_BUTTON') }}
|
||||||
|
</ButtonV4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="pt-8">
|
<div v-else class="pt-8">
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ class Whatsapp::HealthService
|
|||||||
|
|
||||||
def health_fields
|
def health_fields
|
||||||
%w[
|
%w[
|
||||||
|
id
|
||||||
quality_rating
|
quality_rating
|
||||||
messaging_limit_tier
|
messaging_limit_tier
|
||||||
code_verification_status
|
code_verification_status
|
||||||
account_mode
|
account_mode
|
||||||
id
|
|
||||||
display_phone_number
|
display_phone_number
|
||||||
name_status
|
name_status
|
||||||
verified_name
|
verified_name
|
||||||
@@ -68,6 +68,7 @@ class Whatsapp::HealthService
|
|||||||
|
|
||||||
def format_health_response(response)
|
def format_health_response(response)
|
||||||
{
|
{
|
||||||
|
id: response['id'],
|
||||||
display_phone_number: response['display_phone_number'],
|
display_phone_number: response['display_phone_number'],
|
||||||
verified_name: response['verified_name'],
|
verified_name: response['verified_name'],
|
||||||
name_status: response['name_status'],
|
name_status: response['name_status'],
|
||||||
@@ -75,10 +76,20 @@ class Whatsapp::HealthService
|
|||||||
messaging_limit_tier: response['messaging_limit_tier'],
|
messaging_limit_tier: response['messaging_limit_tier'],
|
||||||
account_mode: response['account_mode'],
|
account_mode: response['account_mode'],
|
||||||
code_verification_status: response['code_verification_status'],
|
code_verification_status: response['code_verification_status'],
|
||||||
|
webhook_configuration: response['webhook_configuration'],
|
||||||
|
expected_webhook_url: build_expected_webhook_url,
|
||||||
throughput: response['throughput'],
|
throughput: response['throughput'],
|
||||||
last_onboarded_time: response['last_onboarded_time'],
|
last_onboarded_time: response['last_onboarded_time'],
|
||||||
platform_type: response['platform_type'],
|
platform_type: response['platform_type'],
|
||||||
|
certificate: response['certificate'],
|
||||||
business_id: @channel.provider_config['business_account_id']
|
business_id: @channel.provider_config['business_account_id']
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_expected_webhook_url
|
||||||
|
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||||
|
return nil if frontend_url.blank?
|
||||||
|
|
||||||
|
"#{frontend_url}/webhooks/whatsapp/#{@channel.phone_number}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
class Whatsapp::WebhookSetupService
|
class Whatsapp::WebhookSetupService
|
||||||
def initialize(channel, waba_id, access_token)
|
def initialize(channel, waba_id = nil, access_token = nil)
|
||||||
@channel = channel
|
@channel = channel
|
||||||
@waba_id = waba_id
|
@waba_id = waba_id || channel.provider_config['business_account_id']
|
||||||
@access_token = access_token
|
@access_token = access_token || channel.provider_config['api_key']
|
||||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
@api_client = Whatsapp::FacebookApiClient.new(@access_token)
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
@@ -17,6 +17,11 @@ class Whatsapp::WebhookSetupService
|
|||||||
setup_webhook
|
setup_webhook
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def register_callback
|
||||||
|
validate_parameters!
|
||||||
|
setup_webhook
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def validate_parameters!
|
def validate_parameters!
|
||||||
@@ -33,8 +38,6 @@ class Whatsapp::WebhookSetupService
|
|||||||
store_pin(pin)
|
store_pin(pin)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}")
|
Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}")
|
||||||
# Continue with webhook setup even if registration fails
|
|
||||||
# This is just a warning, not a blocking error
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_or_create_pin
|
def fetch_or_create_pin
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ Rails.application.routes.draw do
|
|||||||
delete :avatar, on: :member
|
delete :avatar, on: :member
|
||||||
post :sync_templates, on: :member
|
post :sync_templates, on: :member
|
||||||
get :health, on: :member
|
get :health, on: :member
|
||||||
|
post :register_webhook, on: :member
|
||||||
if ChatwootApp.enterprise?
|
if ChatwootApp.enterprise?
|
||||||
resource :conference, only: %i[create destroy], controller: 'conference' do
|
resource :conference, only: %i[create destroy], controller: 'conference' do
|
||||||
get :token, on: :member
|
get :token, on: :member
|
||||||
|
|||||||
Reference in New Issue
Block a user