Files
2026-04-28 15:13:50 +02:00

743 lines
19 KiB
PHP

<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use WP_Rocket\Engine\License\API\User;
/**
* Results Controller class.
*
* Handles API calls related to Survey Results
*
* @since 1.0.0
*
* @package UserFeedback
* @author David Paternina
*/
class UserFeedback_Results {
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Registers REST routes
*
* @return void
*/
public function register_routes() {
register_rest_route(
'userfeedback/v1',
'/results-summary',
array(
'methods' => '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<id>\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<id>\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<id>\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<id>\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<id>\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();