diff --git a/app/controllers/super_admin/push_diagnostics_controller.rb b/app/controllers/super_admin/push_diagnostics_controller.rb new file mode 100644 index 000000000..c33cfdc1e --- /dev/null +++ b/app/controllers/super_admin/push_diagnostics_controller.rb @@ -0,0 +1,68 @@ +class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController + def show + @query = params[:user_query].to_s.strip + @user = resolve_user(@query) + @subscriptions = @user ? @user.notification_subscriptions.order(:id) : [] + @results = [] + end + + def create + @user = User.find_by(id: params[:user_id]) + return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil? + + ids = parsed_subscription_ids + if ids.empty? + return redirect_to super_admin_push_diagnostics_path(user_query: @user.id), + alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test') + end + + run_test_and_render(ids) + end + + def destroy_subscriptions + user = User.find_by(id: params[:user_id]) + return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil? + + ids = parsed_subscription_ids + if ids.empty? + return redirect_to super_admin_push_diagnostics_path(user_query: user.id), + alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete') + end + + deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size + log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}") + redirect_to super_admin_push_diagnostics_path(user_query: user.id), + notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count) + end + + private + + def run_test_and_render(ids) + @query = @user.id.to_s + @subscriptions = @user.notification_subscriptions.order(:id) + @results = Notification::PushTestService.new( + user: @user, subscription_ids: ids, + title: params[:push_title], body: params[:push_body] + ).perform + + log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}") + render :show + end + + def log_super_admin_action(message) + Rails.logger.info( + "[SuperAdmin] push diagnostics #{message} " \ + "(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})" + ) + end + + def resolve_user(query) + return if query.blank? + + query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query) + end + + def parsed_subscription_ids + Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i) + end +end diff --git a/app/services/notification/push_test_service.rb b/app/services/notification/push_test_service.rb new file mode 100644 index 000000000..4b2bec177 --- /dev/null +++ b/app/services/notification/push_test_service.rb @@ -0,0 +1,160 @@ +class Notification::PushTestService + pattr_initialize [:user!, :subscription_ids!, :title, :body] + + DEFAULT_TITLE = '%s notification test'.freeze + DEFAULT_BODY = 'This is a test from our team to check notification delivery on your device. No action needed.'.freeze + + def self.default_title + format(DEFAULT_TITLE, installation_name: GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot')) + end + + def self.default_body + DEFAULT_BODY + end + + def perform + selected_subscriptions.map { |subscription| test_send(subscription) } + end + + private + + def resolved_title + title.presence || self.class.default_title + end + + def resolved_body + body.presence || self.class.default_body + end + + def selected_subscriptions + user.notification_subscriptions.where(id: subscription_ids).order(:id) + end + + def test_send(subscription) + if subscription.browser_push? + test_browser_push(subscription) + elsif subscription.fcm? + test_fcm(subscription) + else + result(subscription, subscription.subscription_type.to_s, :skipped, 'Unknown subscription type') + end + end + + def test_browser_push(subscription) + return result(subscription, 'browser_push', :skipped, 'VAPID keys not configured') unless VapidService.public_key + + WebPush.payload_send(**browser_push_payload(subscription)) + result(subscription, 'browser_push', :success, 'Web push accepted by endpoint') + rescue StandardError => e + result(subscription, 'browser_push', :failure, "#{e.class.name}: #{e.message}") + end + + def test_fcm(subscription) + if firebase_credentials_present? + test_fcm_direct(subscription) + elsif chatwoot_hub_enabled? + test_fcm_via_hub(subscription) + else + result(subscription, 'fcm', :skipped, 'No Firebase credentials and push relay disabled') + end + end + + def test_fcm_direct(subscription) + fcm_service = Notification::FcmService.new( + GlobalConfigService.load('FIREBASE_PROJECT_ID', nil), + GlobalConfigService.load('FIREBASE_CREDENTIALS', nil) + ) + response = fcm_service.fcm_client.send_v1(fcm_options(subscription)) + status_code = response[:status_code].to_i + status = status_code.between?(200, 299) ? :success : :failure + result(subscription, 'fcm', status, "HTTP #{status_code} — #{response[:body]}") + rescue StandardError => e + result(subscription, 'fcm', :failure, "#{e.class.name}: #{e.message}") + end + + def test_fcm_via_hub(subscription) + response = ChatwootHub.send_push_with_response(fcm_options(subscription)) + result(subscription, 'fcm_via_hub', :success, "HTTP #{response.code} — #{response.body}") + rescue RestClient::ExceptionWithResponse => e + result(subscription, 'fcm_via_hub', :failure, "HTTP #{e.response&.code} — #{e.response&.body}") + rescue StandardError => e + result(subscription, 'fcm_via_hub', :failure, "#{e.class.name}: #{e.message}") + end + + def firebase_credentials_present? + GlobalConfigService.load('FIREBASE_PROJECT_ID', nil) && GlobalConfigService.load('FIREBASE_CREDENTIALS', nil) + end + + def chatwoot_hub_enabled? + ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true)) + end + + def browser_push_payload(subscription) + { + message: JSON.generate( + title: resolved_title, + tag: "super_admin_test_#{Time.zone.now.to_i}", + url: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com') + ), + endpoint: subscription.subscription_attributes['endpoint'], + p256dh: subscription.subscription_attributes['p256dh'], + auth: subscription.subscription_attributes['auth'], + vapid: { + subject: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com'), + public_key: VapidService.public_key, + private_key: VapidService.private_key + }, + ssl_timeout: 5, + open_timeout: 5, + read_timeout: 5 + } + end + + def fcm_options(subscription) + { + 'token': subscription.subscription_attributes['push_token'], + 'data': { payload: { data: { notification: { type: 'test' } } }.to_json }, + 'notification': { title: resolved_title, body: resolved_body }, + 'android': { priority: 'high' }, + 'apns': { payload: { aps: { sound: 'default', category: Time.zone.now.to_i.to_s } } }, + 'fcm_options': { analytics_label: 'SuperAdminTest' } + } + end + + def result(subscription, type, status, message) + attrs = subscription.subscription_attributes || {} + { + id: subscription.id, + type: type.to_s, + device: device_label(subscription, attrs), + token_tail: token_tail(subscription, attrs), + status: status, + message: message + } + end + + def device_label(subscription, attrs) + if subscription.browser_push? + endpoint_host(attrs['endpoint'].to_s) + else + attrs['device_id'].present? ? "…#{attrs['device_id'].to_s.last(6)}" : '—' + end + end + + def endpoint_host(endpoint) + return '—' if endpoint.blank? + + URI.parse(endpoint).host.presence || endpoint + rescue URI::InvalidURIError + endpoint + end + + def token_tail(subscription, attrs) + if subscription.browser_push? + endpoint = attrs['endpoint'].to_s + endpoint.present? ? "…#{endpoint.last(6)}" : '—' + else + attrs['push_token'].present? ? "…#{attrs['push_token'].to_s.last(6)}" : '—' + end + end +end diff --git a/app/views/super_admin/application/_navigation.html.erb b/app/views/super_admin/application/_navigation.html.erb index 787576d33..da3a024f8 100644 --- a/app/views/super_admin/application/_navigation.html.erb +++ b/app/views/super_admin/application/_navigation.html.erb @@ -32,7 +32,7 @@ as defined by the routes in the `admin/` namespace