'GET', 'callback' => array( $this, 'get_results_summary' ), 'permission_callback' => array( $this, 'view_results_permission_check' ), 'args' => array( 'orderby' => array( 'type' => 'string', 'sanitize_callback' => array( $this, 'sanitize_orderby_param' ), ), 'order' => array( 'type' => 'string', 'sanitize_callback' => array( $this, 'sanitize_order_param' ), ), ), ) ); register_rest_route( 'userfeedback/v1', '/surveys/(?P\w+)/results', array( 'methods' => 'GET', 'callback' => array( $this, 'get_survey_results' ), 'permission_callback' => array( $this, 'view_results_permission_check' ), ) ); register_rest_route( 'userfeedback/v1', '/surveys/(?P\w+)/responses', array( 'methods' => 'GET', 'callback' => array( $this, 'get_survey_responses' ), 'permission_callback' => array( $this, 'view_results_permission_check' ), 'args' => array( 'filter' => array( 'type' => 'object', 'sanitize_callback' => array( $this, 'sanitize_filter_param' ), ), 'per_page' => array( 'type' => 'integer', 'sanitize_callback' => 'absint', ), 'page' => array( 'type' => 'integer', 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( 'userfeedback/v1', '/surveys/(?P\w+)/responses/trash', array( 'methods' => 'POST', 'callback' => array( $this, 'trash_responses' ), 'permission_callback' => array( $this, 'view_results_permission_check' ), 'args' => array( 'response_ids' => array( 'required' => true, 'type' => 'array', 'description' => __('Survey response ids.', 'userfeedback-lite'), 'sanitize_callback' => function($ids) { return array_map('esc_attr', $ids); }, 'validate_callback' => array( $this, 'validate_response_ids' ) ) ) ) ); register_rest_route( 'userfeedback/v1', '/surveys/(?P\w+)/responses/restore', array( 'methods' => 'POST', 'callback' => array( $this, 'restore_responses' ), 'permission_callback' => array( $this, 'view_results_permission_check' ), 'args' => array( 'response_ids' => array( 'required' => true, 'type' => 'array', 'description' => __('Survey response ids.', 'userfeedback-lite'), 'sanitize_callback' => function($ids) { return array_map('esc_attr', $ids); }, 'validate_callback' => array( $this, 'validate_response_ids' ) ) ) ) ); register_rest_route( 'userfeedback/v1', '/surveys/(?P\w+)/responses', array( 'methods' => 'DELETE', 'callback' => array( $this, 'delete_responses' ), 'permission_callback' => array( $this, 'view_results_permission_check' ), 'args' => array( 'response_ids' => array( 'required' => true, 'type' => 'array', 'description' => __('Survey response ids.', 'userfeedback-lite'), 'sanitize_callback' => function($ids) { return array_map('esc_attr', $ids); }, 'validate_callback' => array( $this, 'validate_response_ids' ) ) ) ) ); } /** * Get Survey results data * * @param $survey_id * @return mixed|null */ public static function get_survey_results_data( $survey_id, $from_date = '', $to_date = '' ) { $start_date_7_days = ( new DateTime() )->modify( '-7 days' ); $start_date_30_days = ( new DateTime() )->modify( '-30 days' ); $end_date = new DateTime(); $survey_query = UserFeedback_Survey::where( array( 'id' => $survey_id ) )->select( array( 'title', 'status', 'impressions', 'questions', 'type' ) ); $survey = $survey_query->single(); if ( 'nps' === $survey->type ) { $where_conditions = array( 'survey_id' => $survey_id ); // By default, nps will return last 30 days results including today if ( empty( $from_date ) && empty( $to_date ) ) { $today = new DateTime(); $todayFormatted = $today->format('Y-m-d'); $dateBefore30Days = new DateTime(); $dateBefore30Days->modify('-30 days'); $dateBefore30DaysFormatted = $dateBefore30Days->format('Y-m-d'); $from_date = $dateBefore30DaysFormatted; $to_date = $todayFormatted; } if ( ! empty( $from_date ) ) { $where_conditions[] = array( 'DATE(submitted_at)', '>=', $from_date, ); } if ( ! empty( $to_date ) ) { $where_conditions[] = array( 'DATE(submitted_at)', '<=', $to_date, ); } $responses = UserFeedback_Response::where( $where_conditions ) ->select( array( 'id', 'survey_id', 'answers', 'submitted_at', 'status' ) ) ->get(); $survey->nps_overview = self::get_nps_survey_overview( $responses ); $survey->responses = $responses; } else { $survey = $survey_query->with_count_where( 'responses', array( array( 'submitted_at', '>=', $start_date_7_days->format( 'Y-m-d' ), ), array( 'submitted_at', '<=', $end_date->format( 'Y-m-d' ), ), ), 'responses_count_7_days' ) ->with_count_where( 'responses', array( array( 'submitted_at', '>=', $start_date_30_days->format( 'Y-m-d' ), ), array( 'submitted_at', '<=', $end_date->format( 'Y-m-d' ), ), ), 'responses_count_30_days' ) ->with( array( 'responses' ) ) ->single(); } if ( $survey === null ) { return null; } // Survey total responses $total_responses = sizeof( $survey->responses ); $survey->responses_count = $total_responses; // Survey question stats $quantitative_question_types = array( 'radio-button', 'image-radio', 'icon-choice', 'checkbox', 'nps', 'star-rating' ); $question_stats = array(); $questions = $survey->questions; $responses = $survey->responses; foreach ( $questions as $question ) { $id = $question->id; $type = $question->type; $is_quantitative = in_array( $type, $quantitative_question_types ); $question_data = array( 'id' => $question->id, 'title' => $question->title, 'type' => $question->type, 'total_answers' => 0, 'skipped' => 0, 'is_quantitative' => $is_quantitative, ); if ( $is_quantitative ) { switch ( $type ) { case 'radio-button': case 'image-radio': case 'icon-choice': case 'checkbox': $question_data['options'] = array_map( function ( $option ) { return array( 'value' => $option, 'count' => 0, ); }, $question->config->options ); break; case 'nps': $question_data['options'] = array_map( function ( $option ) { return array( 'value' => $option, 'count' => 0, ); }, array( 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ) ); break; case 'star-rating': $question_data['options'] = array_map( function ( $option ) { return array( 'value' => $option, 'count' => 0, ); }, array( 1, 2, 3, 4, 5 ) ); break; } } else { $question_data['answers'] = array(); } foreach ( $responses as $response ) { $question_answer_index = array_search( $id, array_column( $response->answers, 'question_id' ) ); $value = $response->answers[ $question_answer_index ]->value; $extra = isset( $response->answers[ $question_answer_index ]->extra ) ? $response->answers[ $question_answer_index ]->extra : null; if ( $question_answer_index === false || $value === null ) { $question_data['skipped']++; continue; } else { $question_data['total_answers']++; } if ( $is_quantitative ) { if ( is_array( $value ) ) { foreach ( $value as $picked_value ) { $option_index = array_search( $picked_value, array_column( $question_data['options'], 'value' ) ); $question_data['options'][ $option_index ]['count']++; } } else { if ( in_array( $type, array( 'icon-choice', 'image-radio' ), true ) ) { $question_options = array_map(function($option) { return $option['value']->label; }, $question_data['options']); } else { $question_options = array_column( $question_data['options'], 'value' ); } $option_index = array_search( $value, $question_options ); $question_data['options'][ $option_index ]['count']++; } } $question_data['answers'][] = array( 'response_id' => $response->id, 'value' => $value, 'date' => $response->submitted_at, 'extra' => $extra, ); } $question_stats[] = $question_data; } $survey->question_stats = $question_stats; return $survey; } private static function get_nps_survey_overview( $responses = array() ) { $totalCount = count($responses); if ($totalCount > 0) { $detractors = 0; $passives = 0; $promoters = 0; foreach ($responses as $item) { if (!isset($item->answers[0]->value)) { continue; } $value = $item->answers[0]->value; if ($value >= 1 && $value <= 6) { $detractors++; } elseif ($value >= 7 && $value <= 8) { $passives++; } elseif ($value >= 9 && $value <= 10) { $promoters++; } } // Calculate percentages and round to 2 decimal places $detractorsPercentage = floor(($detractors / $totalCount) * 100); $passivesPercentage = floor(($passives / $totalCount) * 100); $promotersPercentage = floor(($promoters / $totalCount) * 100); return array( 'detractor' => $detractorsPercentage, 'passive' => $passivesPercentage, 'promoter' => $promotersPercentage, 'nps' => floor($promotersPercentage - $detractorsPercentage), ); } return array( 'detractor' => 0, 'passive' => 0, 'promoter' => 0, 'nps' => 'N/A', ); } /** * Permissions/capabilities check * * @return bool */ public function view_results_permission_check() { return current_user_can( 'userfeedback_view_results' ); } /** * Sanitize filter parameter to prevent SQL injection * * @param mixed $filters The filter parameter value * @return array Sanitized filters with only allowed keys */ public function sanitize_filter_param( $filters ) { if ( ! is_array( $filters ) ) { return array(); } $allowed_filter_attrs = array( 'status' ); $sanitized = array(); foreach ( $filters as $key => $value ) { // Only allow whitelisted column names $sanitized_key = sanitize_key( $key ); if ( in_array( $sanitized_key, $allowed_filter_attrs, true ) ) { $sanitized[ $sanitized_key ] = sanitize_text_field( $value ); } } return $sanitized; } /** * Sanitize orderby parameter to prevent SQL injection * * @param string $orderby * @return string */ public function sanitize_orderby_param( $orderby ) { // Allowed columns for survey queries $allowed_orderby = array( 'id', 'title', 'status', 'type', 'impressions', 'publish_at', 'created_at' ); $orderby = sanitize_key( $orderby ); return in_array( $orderby, $allowed_orderby, true ) ? $orderby : 'created_at'; } /** * Sanitize order parameter * * @param string $order * @return string */ public function sanitize_order_param( $order ) { $order = strtolower( sanitize_key( $order ) ); return in_array( $order, array( 'asc', 'desc' ), true ) ? $order : 'desc'; } /** * Validate response ids callback * * @return bool */ public function validate_response_ids($ids) { global $wpdb; $table_name = $wpdb->prefix . 'userfeedback_survey_responses'; $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is hardcoded safe prefix + known table name. $query = $wpdb->prepare( "SELECT * FROM {$table_name} WHERE id IN ({$placeholders})", ...$ids ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared via $wpdb->prepare() above; direct query required for IN() validation. $result = $wpdb->get_results( $query ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter return count($result) === count($ids); } /** * Get Results summary by date range * * @param WP_REST_Request $request * @return WP_REST_Response * @throws Exception */ public function get_results_summary( WP_REST_Request $request ) { $start_date = $request->get_param( 'start_date' ); $end_date = $request->get_param( 'end_date' ); $survey_id = $request->get_param( 'survey_id' ); $orderby = $request->get_param( 'orderby' ); $order = $request->get_param( 'order' ); $orderby = empty( $orderby ) ? 'created_at' : $orderby; $order = empty( $order ) ? 'desc' : $order; $start_date = $start_date ? new DateTime( $start_date ) : ( new DateTime() )->modify( '-7 days' )->setTime( 0, 0 ); if ( ! userfeedback_is_pro_version() || ! userfeedback_is_licensed() ) { $start_date = ( new DateTime() )->modify( '-7 days' )->setTime( 0, 0 ); } $end_date = $end_date ? new DateTime( $end_date ) : new DateTime(); $end_date = $end_date->modify( '+1 day' ); $where_config = array( array( 'submitted_at', '>=', $start_date->format( 'Y-m-d' ), ), array( 'submitted_at', '<=', $end_date->format( 'Y-m-d' ), ), ); if ( isset( $survey_id ) ) { $where_config[] = array( 'survey_id', '=', $survey_id, ); } $responses_query_obj = UserFeedback_Response::query(); /** * Get responses */ $responses = UserFeedback_Response::where( $where_config ) ->select( array( "{$responses_query_obj->get_table()}.id", "{$responses_query_obj->get_table()}.survey_id", "{$responses_query_obj->get_table()}.submitted_at", ) ) ->get(); /* * Get data for graph */ $data_points = array(); $iterate_date = $start_date; while ( $iterate_date->diff( $end_date )->days > 0 ) { $responses_for_date = array_filter( $responses, function( $response ) use ( $iterate_date ) { $response_date = new DateTime( $response->submitted_at ); return $iterate_date->format( 'Y-m-d' ) === $response_date->format( 'Y-m-d' ); } ); $data_points[] = array( 'date' => $iterate_date->format( 'Y-m-d' ), 'count' => sizeof( $responses_for_date ), ); $iterate_date->modify( '+1 day' ); } /* * Get Surveys info */ $surveys_query = UserFeedback_Survey::where( array( array( 'status', '!=', 'trash' ), ) ); $surveys_query->select( array( 'title', 'type', 'status', 'created_at' ) ) ->with_count( array( 'responses' ) ) ->with_count_where( 'responses', $where_config, 'range_responses_count' ) ->sort( $orderby, $order ); return new WP_REST_Response( array( 'total_responses' => sizeof( $responses ), 'data_points' => $data_points, 'surveys' => $surveys_query->get(), ) ); } /** * Get Survey with responses * * @param WP_REST_Request $request * @return WP_REST_Response */ public function get_survey_results( WP_REST_Request $request ) { $survey_id = $request['id']; $from_date = $request['from']; $to_date = $request['to']; $survey = self::get_survey_results_data( $survey_id, $from_date, $to_date ); if ( $survey === null ) { return new WP_REST_Response( null, 404 ); } // Remove the original questions and responses arrays to get a cleaner AJAX response unset( $survey->questions ); unset( $survey->responses ); return new WP_REST_Response( $survey ); } /** * Get Survey responses * * @param WP_REST_Request $request * @return WP_REST_Response */ public function get_survey_responses( WP_REST_Request $request ) { (new UserFeedback_Response)->add_status_column(); $survey_id = $request->get_param( 'id' ); // Get responses $query = UserFeedback_Response::where( array( 'survey_id' => $survey_id, array( 'status', '!=', 'trash' ), // Get only published and drafts by default ) ) ->sort( 'id', 'desc' ) ->paginate( $request->get_param( 'per_page' ), $request->get_param( 'page' ) ); if ( $request->has_param( 'filter' ) ) { $filters = $request->get_param( 'filter' ); // Sanitization callback should have already cleaned this if ( ! is_array( $filters ) ) { $filters = array(); } foreach ( $filters as $attr => $value ) { if ( $value === 'all' ) { $query->add_where( array( array('status', '!=', 'trash'), ) ); break; } // Only 'status' should reach here due to sanitization $query->add_where( array( array( $attr, '=', $value ), ) ); } } $responses = $query->get(); // Data for quick filters $count_by_status_result = UserFeedback_Response::query() ->select( array( 'status', 'count' ) ) ->where( array( 'survey_id' => $survey_id ) ) ->group_by( 'status' ) ->get(); $allTotal = 0; foreach ( $count_by_status_result as $item ) { if ( $item->status !== 'trash' ) { $allTotal += $item->count; } if ( $item->status === 'publish' ) { $item->status = 'publish'; } } array_unshift( $count_by_status_result, array( 'status' => 'all', 'count' => $allTotal, ) ); $responses['status_filters'] = $count_by_status_result; return new WP_REST_Response( $responses ); } /** * Trash responses * * @param WP_REST_Request $request * @return WP_REST_Response */ public function trash_responses( WP_REST_Request $request ) { $response_ids = $request->get_param( 'response_ids' ); // Trash responses $responses = UserFeedback_Response::trash($response_ids); return new WP_REST_Response( $responses ); } /** * Restore responses * * @param WP_REST_Request $request * @return WP_REST_Response */ public function restore_responses( WP_REST_Request $request ) { $response_ids = $request->get_param( 'response_ids' ); // Restore responses $responses = UserFeedback_Response::restore($response_ids); return new WP_REST_Response( $responses ); } /** * Delete responses by Id * * @return WP_REST_Response */ public function delete_responses( $data ) { $response_ids = $data['response_ids']; // Delete responses UserFeedback_Response::delete($response_ids); return new WP_REST_Response( null, 204 ); } } new UserFeedback_Results();