* @license GPL-2.0+ http://www.gnu.org/licenses/gpl-2.0.txt * @link https://aisk.com */ if ( ! defined('ABSPATH') ) { exit; } /** * AISK Admin Class handles all admin-related functionality * * @category Class * @package AISK * @author Aisk Team * @license GPL-2.0+ http://www.gnu.org/licenses/gpl-2.0.txt * @link https://aisk.com */ class AISK_Admin { private $plugin_slug = 'aisk'; private static $instance = null; /** * Get singleton instance of the class * * @since 1.0.0 * * @return AISK_Admin Instance of the class */ public static function get_instance() { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Constructor */ public function __construct() { add_action('admin_menu', [ $this, 'register_admin_menu' ]); add_action('admin_enqueue_scripts', [ $this, 'enqueue_admin_scripts' ]); add_action('admin_enqueue_scripts', [ $this, 'enqueue_admin_styles' ]); add_action('rest_api_init', [ $this, 'aisk_register_settings_endpoints' ]); } /** * Menu Left Style */ public function enqueue_admin_styles( $hook_suffix ) { // On Aisk pages this CSS is loaded after chat-admin.css (see enqueue_admin_scripts). if ( $this->is_aisk_admin_hook( $hook_suffix ) ) { return; } wp_enqueue_style( 'aisk-admin-style', plugin_dir_url( dirname( __FILE__ ) ) . 'assets/css/admin-style.css', [], AISK_VERSION ); } /** * Register admin menu items * * @since 1.0.0 * * @return void */ public function register_admin_menu() { add_menu_page( esc_html__( 'Aisk', 'aisk-ai-chat' ), esc_html__( 'Aisk', 'aisk-ai-chat' ), 'manage_options', $this->plugin_slug, [ $this, 'render_dashboard_page' ], plugin_dir_url( dirname( __FILE__ ) ) . 'assets/images/icons/menu-icon-short.svg', 30 ); add_submenu_page( $this->plugin_slug, esc_html__( 'Inquiries', 'aisk-ai-chat' ), esc_html__( 'Inquiries', 'aisk-ai-chat' ), 'manage_options', $this->plugin_slug . '-inquiries', [ $this, 'render_inquiries_page' ] ); // Add Chat History submenu add_submenu_page( $this->plugin_slug, esc_html__( 'Chat History', 'aisk-ai-chat' ), esc_html__( 'Chat History', 'aisk-ai-chat' ), 'manage_options', $this->plugin_slug . '-history', [ $this, 'render_history_page' ] ); // Add Uses Analytics submenu add_submenu_page( $this->plugin_slug, esc_html__( 'API Usage', 'aisk-ai-chat' ), esc_html__( 'API Usage', 'aisk-ai-chat' ), 'manage_options', $this->plugin_slug . '-uses', [ $this, 'render_uses_page' ] ); // Add Settings submenu add_submenu_page( $this->plugin_slug, esc_html__( 'Settings', 'aisk-ai-chat' ), esc_html__( 'Settings', 'aisk-ai-chat' ), 'manage_options', $this->plugin_slug . '-settings', [ $this, 'render_settings_page' ] ); // Remove default submenu page remove_submenu_page($this->plugin_slug, $this->plugin_slug); } /** * Enqueue admin scripts and styles * * @param string $hook Current admin page hook * * @since 1.0.0 * * @return void */ public function enqueue_admin_scripts($hook) { if ( ! $this->is_aisk_admin_hook( $hook ) ) { return; } // First load the common chat widget assets AISK_Scripts::load_chat_widget_assets(); // Then load admin-specific assets wp_enqueue_media(); // Register and enqueue admin styles wp_register_style( 'aisk-admin', AISK_PLUGIN_URL . 'build/chat-admin.css', [], AISK_VERSION ); wp_enqueue_style('aisk-admin'); // Load protective admin-menu CSS after Tailwind so dashicons stay intact. wp_enqueue_style( 'aisk-admin-style', plugin_dir_url( dirname( __FILE__ ) ) . 'assets/css/admin-style.css', [ 'aisk-admin' ], AISK_VERSION ); // Ensure i18n is available before the admin IIFE (build expects wp.i18n). wp_enqueue_script( 'wp-hooks' ); wp_enqueue_script( 'wp-i18n' ); // Register and enqueue admin scripts. // Avoid "defer" so wp.i18n + locale data always run before chat-admin.js. wp_register_script( 'aisk-admin', AISK_PLUGIN_URL . 'build/chat-admin.js', [ 'wp-hooks', 'wp-i18n', 'wp-element', 'wp-components', 'wp-api-fetch' ], AISK_VERSION, true ); wp_enqueue_script( 'aisk-admin' ); wp_localize_script( 'aisk-admin', 'AiskSettings', [ 'apiUrl' => rest_url( 'aisk/v1' ), 'restUrl' => rest_url(), 'telegramWebhookUrl' => rest_url( 'aisk/v1/telegram-webhook' ), 'telegramWebhookSecret' => self::get_telegram_webhook_secret(), 'nonce' => wp_create_nonce( 'wp_rest' ), 'pluginUrl' => AISK_PLUGIN_URL, 'adminUrl' => admin_url( 'admin.php' ), 'isWooCommerceActive' => class_exists( 'WooCommerce' ), 'pluginInstallUrl' => admin_url( 'plugin-install.php?s=woocommerce&tab=search&type=term' ), 'maxUploadSize' => wp_max_upload_size(), ] ); wp_set_script_translations( 'aisk-admin', 'aisk-ai-chat', AISK_PLUGIN_DIR . 'languages' ); self::inject_admin_script_translations(); } /** * Get (and lazily create) the secret token Telegram must send with webhook calls. * * @since 2.7.1 * * @return string */ public static function get_telegram_webhook_secret() { $secret = get_option( 'aisk_telegram_webhook_secret' ); if ( ! is_string( $secret ) || '' === $secret ) { $secret = wp_generate_password( 48, false, false ); update_option( 'aisk_telegram_webhook_secret', $secret, false ); } return $secret; } /** * Inject Jed locale data before the admin script. * * Fallback when wp_set_script_translations does not print usable catalogs. * * @return void */ private static function inject_admin_script_translations() { $json = load_script_textdomain( 'aisk-admin', 'aisk-ai-chat', AISK_PLUGIN_DIR . 'languages' ); if ( ! $json ) { $locale = function_exists( 'determine_locale' ) ? determine_locale() : get_locale(); $handle_f = AISK_PLUGIN_DIR . 'languages/aisk-ai-chat-' . $locale . '-aisk-admin.json'; if ( is_readable( $handle_f ) ) { $json = file_get_contents( $handle_f ); } } if ( ! $json ) { return; } $inline = sprintf( '( function( domain, translations ) { if ( ! window.wp || ! wp.i18n || ! wp.i18n.setLocaleData ) { return; } var localeData = translations.locale_data[ domain ] || translations.locale_data.messages; if ( ! localeData ) { return; } localeData[""] = localeData[""] || {}; localeData[""].domain = domain; wp.i18n.setLocaleData( localeData, domain ); } )( "aisk-ai-chat", %s );', $json ); wp_add_inline_script( 'aisk-admin', $inline, 'before' ); } /** * Whether the current admin screen hook belongs to this plugin. * * Uses the enqueue hook suffix only (no request input) so assets can be * scoped without nonce checks. * * @param string $hook Admin page hook suffix. * @return bool */ private function is_aisk_admin_hook( $hook ) { $allowed_hooks = [ 'toplevel_page_' . $this->plugin_slug, $this->plugin_slug . '_page_' . $this->plugin_slug . '-inquiries', $this->plugin_slug . '_page_' . $this->plugin_slug . '-history', $this->plugin_slug . '_page_' . $this->plugin_slug . '-uses', $this->plugin_slug . '_page_' . $this->plugin_slug . '-settings', ]; if ( in_array( $hook, $allowed_hooks, true ) ) { return true; } // Fallback when another plugin alters the exact hook suffix. return is_string( $hook ) && false !== strpos( $hook, $this->plugin_slug ); } /** * Register REST API endpoints for settings * * @since 1.0.0 * * @return void */ public function aisk_register_settings_endpoints() { register_rest_route( 'aisk/v1', '/settings', [ [ 'methods' => 'GET', 'callback' => [ $this, 'aisk_get_settings' ], 'permission_callback' => function () { return current_user_can('manage_options'); }, ], [ 'methods' => 'POST', 'callback' => [ $this, 'aisk_update_settings' ], 'permission_callback' => function () { return current_user_can('manage_options'); }, ], ] ); register_rest_route('aisk/v1', '/models', array( 'methods' => 'GET', 'callback' => function ( $request ) { return rest_ensure_response( AISK_Model_Manager::get_available_models( (bool) $request->get_param('refresh') ) ); }, 'permission_callback' => function () { return current_user_can('manage_options'); }, )); // Analytics endpoints register_rest_route('aisk/v1', '/analytics/overview', array( 'methods' => 'GET', 'callback' => array( $this, 'get_analytics_overview' ), 'permission_callback' => function () { return current_user_can('manage_options'); }, )); register_rest_route('aisk/v1', '/analytics/usage', array( 'methods' => 'GET', 'callback' => array( $this, 'get_analytics_usage' ), 'permission_callback' => function () { return current_user_can('manage_options'); }, )); register_rest_route('aisk/v1', '/analytics/errors', array( 'methods' => 'GET', 'callback' => array( $this, 'get_analytics_errors' ), 'permission_callback' => function () { return current_user_can('manage_options'); }, )); register_rest_route('aisk/v1', '/analytics/costs', array( 'methods' => 'GET', 'callback' => array( $this, 'get_analytics_costs' ), 'permission_callback' => function () { return current_user_can('manage_options'); }, )); } /** * Render inquiries admin page * * @since 1.0.0 * * @return void */ public function render_inquiries_page() { echo '
'; } /** * Render chat history admin page * * @since 1.0.0 * * @return void */ public function render_history_page() { echo '
'; } /** * Render uses analytics admin page * * @since 1.0.0 * * @return void */ public function render_uses_page() { echo '
'; } /** * Render settings admin page * * @since 1.0.0 * * @return void */ public function render_settings_page() { echo '
'; } /** * Get plugin settings * * @since 1.0.0 * * @return WP_REST_Response */ public function aisk_get_settings() { $settings = get_option('aisk_settings', []); return rest_ensure_response($settings); } /** * Update plugin settings * * @param WP_REST_Request $request Request object * * @since 1.0.0 * * @return WP_REST_Response */ public function aisk_update_settings( $request ) { $settings = $request->get_json_params(); if ( ! is_array( $settings ) ) { return new WP_Error( 'invalid_settings', __( 'Invalid settings payload.', 'aisk-ai-chat' ), [ 'status' => 400 ] ); } update_option( 'aisk_settings', $this->sanitize_settings( $settings ) ); return rest_ensure_response([ 'success' => true ]); } /** * Recursively sanitize the settings array before it is stored * * @param mixed $value Settings value * @param string $key Key of the value being sanitized * * @since 2.7.1 * * @return mixed */ private function sanitize_settings( $value, $key = '' ) { if ( is_array( $value ) ) { $clean = []; foreach ( $value as $child_key => $child_value ) { $child_key = is_int( $child_key ) ? $child_key : preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $child_key ); $clean[ $child_key ] = $this->sanitize_settings( $child_value, (string) $child_key ); } return $clean; } if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || null === $value ) { return $value; } $value = (string) $value; // Media/URL fields if ( in_array( $key, [ 'chat_icon', 'widget_logo', 'webhook_url' ], true ) || '_url' === substr( $key, -4 ) ) { return esc_url_raw( $value ); } // Keeps line breaks for multi-line fields (contact info, custom content, welcome messages) return sanitize_textarea_field( $value ); } /** * Get analytics overview data * * @param WP_REST_Request $request Request object * * @since 1.0.0 * * @return WP_REST_Response */ public function get_analytics_overview( $request ) { $time_filter = $request->get_param('time_filter') ?: '7days'; global $wpdb; $table_name = $wpdb->prefix . 'aisk_api_usage'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table names cannot be parameterized, analytics data needs real-time queries // Calculate date range based on filter $days = 7; switch ($time_filter) { case '30days': $days = 30; break; case '90days': $days = 90; break; } $date_from = gmdate('Y-m-d H:i:s', strtotime("-{$days} days")); $feature = $request->get_param('feature'); $feature = is_string($feature) ? sanitize_text_field($feature) : ''; // Get total requests if (!empty($feature) && $feature !== 'all') { $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $total_requests = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table_name_sql} WHERE created_at >= %s AND feature = %s", $date_from, $feature )); } else { $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $total_requests = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table_name_sql} WHERE created_at >= %s", $date_from )); } // Get chat requests (not filtered by feature to show overall chat volume) $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $chat_requests = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table_name_sql} WHERE created_at >= %s AND feature = 'chat'", $date_from )); // Get classify requests (not filtered by feature to show overall classify volume) $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $classify_requests = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table_name_sql} WHERE created_at >= %s AND feature = 'classify'", $date_from )); // Get successful requests if (!empty($feature) && $feature !== 'all') { $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $successful_requests = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table_name_sql} WHERE created_at >= %s AND status = 'success' AND feature = %s", $date_from, $feature )); } else { $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $successful_requests = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table_name_sql} WHERE created_at >= %s AND status = 'success'", $date_from )); } // Calculate success rate $success_rate = $total_requests > 0 ? round(($successful_requests / $total_requests) * 100, 1) : 0; // Get average latency if (!empty($feature) && $feature !== 'all') { $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $avg_latency = $wpdb->get_var($wpdb->prepare( "SELECT AVG(latency_ms) FROM {$table_name_sql} WHERE created_at >= %s AND latency_ms > 0 AND feature = %s", $date_from, $feature )); } else { $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $avg_latency = $wpdb->get_var($wpdb->prepare( "SELECT AVG(latency_ms) FROM {$table_name_sql} WHERE created_at >= %s AND latency_ms > 0", $date_from )); } $avg_latency = $avg_latency ? round($avg_latency / 1000, 2) : 0; // Convert to seconds // Total cost removed as requested // Get total tokens (include system prompt tokens from metadata.system_tokens) if (!empty($feature) && $feature !== 'all') { $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $total_tokens = $wpdb->get_var($wpdb->prepare( "SELECT SUM((tokens_in + tokens_out + COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.system_tokens')) AS UNSIGNED), 0))) FROM {$table_name_sql} WHERE created_at >= %s AND feature = %s", $date_from, $feature )); } else { $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $total_tokens = $wpdb->get_var($wpdb->prepare( "SELECT SUM((tokens_in + tokens_out + COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.system_tokens')) AS UNSIGNED), 0))) FROM {$table_name_sql} WHERE created_at >= %s", $date_from )); } $total_tokens = $total_tokens ? intval($total_tokens) : 0; // Calculate error rate $error_rate = $total_requests > 0 ? round((($total_requests - $successful_requests) / $total_requests) * 100, 1) : 0; $overview_data = [ 'totalRequests' => intval($total_requests), 'chatRequests' => intval($chat_requests), 'classifyRequests' => intval($classify_requests), 'successRate' => $success_rate, 'avgLatency' => $avg_latency, // 'totalCost' removed 'totalTokens' => $total_tokens, 'errorRate' => $error_rate ]; // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return rest_ensure_response($overview_data); } /** * Get analytics usage data * * @param WP_REST_Request $request Request object * * @since 1.0.0 * * @return WP_REST_Response */ public function get_analytics_usage( $request ) { $time_filter = $request->get_param('time_filter') ?: '7days'; $feature = $request->get_param('feature'); $feature = is_string($feature) ? sanitize_text_field($feature) : ''; global $wpdb; $table_name = $wpdb->prefix . 'aisk_api_usage'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table names cannot be parameterized, analytics data needs real-time queries // Calculate date range based on filter $days = 7; switch ($time_filter) { case '30days': $days = 30; break; case '90days': $days = 90; break; } $date_from = gmdate('Y-m-d H:i:s', strtotime("-{$days} days")); // Get daily usage data (include system prompt tokens from metadata.system_tokens) if (!empty($feature) && $feature !== 'all') { $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $usage_data = $wpdb->get_results($wpdb->prepare( "SELECT DATE(created_at) as date, COUNT(*) as requests, SUM((tokens_in + tokens_out + COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.system_tokens')) AS UNSIGNED), 0))) as tokens FROM {$table_name_sql} WHERE created_at >= %s AND feature = %s GROUP BY DATE(created_at) ORDER BY date ASC", $date_from, $feature )); } else { $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $usage_data = $wpdb->get_results($wpdb->prepare( "SELECT DATE(created_at) as date, COUNT(*) as requests, SUM((tokens_in + tokens_out + COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.system_tokens')) AS UNSIGNED), 0))) as tokens FROM {$table_name_sql} WHERE created_at >= %s GROUP BY DATE(created_at) ORDER BY date ASC", $date_from )); } // Format the data $formatted_data = []; foreach ($usage_data as $row) { $formatted_data[] = [ 'date' => $row->date, 'requests' => intval($row->requests), 'tokens' => intval($row->tokens) ]; } // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return rest_ensure_response($formatted_data); } /** * Get analytics errors data * * @param WP_REST_Request $request Request object * * @since 1.0.0 * * @return WP_REST_Response */ public function get_analytics_errors( $request ) { $time_filter = $request->get_param('time_filter') ?: '7days'; $feature = $request->get_param('feature'); $feature = is_string($feature) ? sanitize_text_field($feature) : ''; global $wpdb; $table_name = $wpdb->prefix . 'aisk_api_usage'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table names cannot be parameterized, analytics data needs real-time queries // Calculate date range based on filter $days = 7; switch ($time_filter) { case '30days': $days = 30; break; case '90days': $days = 90; break; } $date_from = gmdate('Y-m-d H:i:s', strtotime("-{$days} days")); // Get error data if (!empty($feature) && $feature !== 'all') { $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $errors_data = $wpdb->get_results($wpdb->prepare( "SELECT error_code, COUNT(*) as count FROM {$table_name_sql} WHERE created_at >= %s AND status != 'success' AND error_code IS NOT NULL AND feature = %s GROUP BY error_code ORDER BY count DESC", $date_from, $feature )); } else { $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $errors_data = $wpdb->get_results($wpdb->prepare( "SELECT error_code, COUNT(*) as count FROM {$table_name_sql} WHERE created_at >= %s AND status != 'success' AND error_code IS NOT NULL GROUP BY error_code ORDER BY count DESC", $date_from )); } // Calculate total errors for percentage $total_errors = 0; foreach ($errors_data as $row) { $total_errors += intval($row->count); } // Format the data $formatted_data = []; foreach ($errors_data as $row) { $percentage = $total_errors > 0 ? round((intval($row->count) / $total_errors) * 100, 1) : 0; $formatted_data[] = [ 'error' => ucfirst(str_replace('_', ' ', $row->error_code)), 'count' => intval($row->count), 'percentage' => $percentage ]; } // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return rest_ensure_response($formatted_data); } /** * Get analytics costs data * * @param WP_REST_Request $request Request object * * @since 1.0.0 * * @return WP_REST_Response */ public function get_analytics_costs( $request ) { $time_filter = $request->get_param('time_filter') ?: '7days'; global $wpdb; $table_name = $wpdb->prefix . 'aisk_api_usage'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table names cannot be parameterized, analytics data needs real-time queries // Calculate date range based on filter $days = 7; switch ($time_filter) { case '30days': $days = 30; break; case '90days': $days = 90; break; } $date_from = gmdate('Y-m-d H:i:s', strtotime("-{$days} days")); // Get daily OpenAI cost data only (current provider) $table_name_sql = esc_sql($table_name); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be parameterized $costs_data = $wpdb->get_results($wpdb->prepare( "SELECT DATE(created_at) as date, provider, SUM(cost_usd) as cost FROM {$table_name_sql} WHERE created_at >= %s AND provider = 'openai' GROUP BY DATE(created_at), provider ORDER BY date ASC, provider ASC", $date_from )); // Format the data by date and provider $formatted_data = []; $date_costs = []; foreach ($costs_data as $row) { $date = $row->date; if (!isset($date_costs[$date])) { $date_costs[$date] = ['date' => $date, 'total' => 0]; } $date_costs[$date]['total'] += floatval($row->cost); $date_costs[$date][strtolower($row->provider)] = round(floatval($row->cost), 2); } // Convert to array format foreach ($date_costs as $date => $costs) { $costs['total'] = round($costs['total'], 2); $formatted_data[] = $costs; } // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return rest_ensure_response($formatted_data); } } AISK_Admin::get_instance();