namespace = ACADEMY_PLUGIN_SLUG . '/v1'; $self->rest_base = 'question_answer'; add_action( 'rest_api_init', array( $self, 'register_routes' ) ); add_filter( 'rest_post_dispatch', array( $self, 'add_x_wp_total_header' ), 10, 3 ); add_action( 'wp_ajax_academy/insert_qa', array( $self, 'insert_qa' ) ); add_action( 'wp_ajax_academy/update_qa', array( $self, 'update_qa' ) ); add_action( 'wp_ajax_academy/delete_qa', array( $self, 'delete_qa' ) ); add_filter( 'post_type_link', array( $self, 'academy_lesson_post_permalink' ), 1, 2 ); } /** * Registers the routes for comments. * * @since 4.7.0 * * @see register_rest_route() */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } public function get_items( $request ) { $course_id = $request->get_param( 'post' ); $page = $request->get_param( 'page' ); $status = $request->get_param( 'status' ); $parent = $request->get_param( 'parent' ); $per_page = $request->get_param( 'per_page' ); $offset = ( $page - 1 ) * $per_page; $user_id = get_current_user_id(); $instructor_course_ids = \Academy\Helper::get_assigned_courses_ids_by_instructor_id( $user_id ); $response = []; if ( empty( $instructor_course_ids ) && ( empty( $course_id ) ) ) { return rest_ensure_response( [] ); } $args = array( 'status' => $status, 'parent' => $parent, 'offset' => $offset, 'page' => $page, 'number' => $per_page, 'type' => 'academy_qa', ); if ( isset( $course_id ) ) { $args['post_id'] = $course_id; unset( $args['parent'] ); } // Scope the thread to a single curriculum item (lesson/quiz/assignment) when // a topic id is supplied. Both questions and their answers carry the topic // meta (answers inherit it from their parent), so threads stay intact. $topic_id = $request->get_param( 'topic' ); if ( ! empty( $topic_id ) ) { // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- meta/tax lookup the feature depends on; no cheaper equivalent $args['meta_query'] = array( array( 'key' => 'academy_qa_topic_id', 'value' => (int) $topic_id, 'compare' => '=', ), ); // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_query } if ( ! current_user_can( 'manage_options' ) && ! empty( $instructor_course_ids ) ) { $args['post__in'] = $instructor_course_ids; } $comments = get_comments( $args ); foreach ( $comments as $comment ) { $response[] = $this->prepare_comment_for_response( $comment ); } return rest_ensure_response( $response ); } /** * Checks if a given request has access to read comments. * * @since 4.7.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access, error object otherwise. */ public function get_items_permissions_check( $request ) { $is_administrator = current_user_can( 'manage_options' ); $manage_academy_instructor = current_user_can( 'manage_academy_instructor' ); $user_ID = get_current_user_id(); if ( ! empty( $request['post'] ) ) { foreach ( (array) $request['post'] as $post_id ) { $is_public = \Academy\Helper::get_course_type( $post_id ) === 'public' ? true : false; $enrolled = \Academy\Helper::is_enrolled( $post_id, $user_ID ); $is_instructor = \Academy\Helper::is_instructor_of_this_course( $user_ID, $post_id ); if ( $is_administrator || $enrolled || $is_instructor || $is_public ) { return true; } } } if ( $is_administrator || $manage_academy_instructor ) { return true; } return false; } public function get_collection_params() { return array( 'page' => array( 'description' => __( 'Current page of the collection.', 'academy' ), 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 1, ), 'per_page' => array( 'description' => __( 'Maximum number of items to be returned in result set.', 'academy' ), 'type' => 'integer', 'default' => 10, 'minimum' => 1, 'maximum' => 100, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'post' => array( 'description' => __( 'Course id is missing', 'academy' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'author' => array( 'description' => __( 'Query by author id', 'academy' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'topic' => array( 'description' => __( 'Scope questions to a single curriculum item (lesson/quiz/assignment) id.', 'academy' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'search' => array( 'description' => __( 'Limit results to those matching a string.', 'academy' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ), ); } public function prepare_comment_for_response( $comment ) { if ( ! is_object( $comment ) ) { return []; } return [ 'id' => (int) $comment->comment_ID, 'post' => (int) $comment->comment_post_ID, '_post' => [ 'title' => get_the_title( $comment->comment_post_ID ), 'permalink' => get_the_permalink( $comment->comment_post_ID ), ], 'parent' => (int) $comment->comment_parent, 'author' => (int) $comment->user_id, 'author_name' => $comment->comment_author, 'avatar_url' => esc_url( get_avatar_url( $comment->user_id, [ 'size' => '40' ] ) ), 'date' => $comment->comment_date, 'date_gmt' => $comment->comment_date_gmt, 'content' => [ 'rendered' => $comment->comment_content, ], 'status' => $comment->comment_approved, 'type' => $comment->comment_type, 'meta' => [ 'question_title' => get_comment_meta( (int) $comment->comment_ID, 'academy_question_title', true ), 'topic_id' => (int) get_comment_meta( (int) $comment->comment_ID, 'academy_qa_topic_id', true ), 'topic_type' => get_comment_meta( (int) $comment->comment_ID, 'academy_qa_topic_type', true ), ] ]; } public function insert_qa() { check_ajax_referer( 'academy_nonce', 'security' ); $course_id = isset( $_POST['post'] ) ? absint( $_POST['post'] ) : 0; $is_administrator = current_user_can( 'manage_options' ); $is_instructor = \Academy\Helper::is_instructor_of_this_course( get_current_user_id(), $course_id ); $enrolled = \Academy\Helper::is_enrolled( $course_id, get_current_user_id() ); $is_public = \Academy\Helper::get_course_type( $course_id ) === 'public' ? true : false; if ( $is_administrator || $is_instructor || $enrolled || $is_public ) { $parent = isset( $_POST['parent'] ) ? absint( $_POST['parent'] ) : 0; $content = isset( $_POST['content'] ) ? sanitize_text_field( wp_unslash( $_POST['content'] ) ) : ''; // A reply has to answer a Q&A thread of this same course. if ( $parent > 0 ) { $parent_comment = get_comment( $parent ); if ( ! $parent_comment || 'academy_qa' !== $parent_comment->comment_type || (int) $parent_comment->comment_post_ID !== $course_id ) { wp_send_json_error( __( 'Sorry, you have not permission to create QA.', 'academy' ) ); } } // The status follows from what is posted, never from the request: a // new question waits for an answer, a reply answers it. $comment_approved = $parent > 0 ? 'answered' : 'waiting_for_answer'; $title = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : ''; $current_user = wp_get_current_user(); // Curriculum-item context so the drawer can scope "On this lesson/quiz/…" // vs "All course". Replies inherit their question's topic so the whole // thread stays inside the same item scope. $topic_id = isset( $_POST['topic_id'] ) ? absint( $_POST['topic_id'] ) : 0; $topic_type = isset( $_POST['topic_type'] ) ? sanitize_text_field( wp_unslash( $_POST['topic_type'] ) ) : ''; if ( $parent > 0 ) { $inherited_topic = (int) get_comment_meta( $parent, 'academy_qa_topic_id', true ); if ( $inherited_topic ) { $topic_id = $inherited_topic; $topic_type = get_comment_meta( $parent, 'academy_qa_topic_type', true ); } } $comment_meta = array( 'academy_question_title' => $title ); if ( $topic_id > 0 ) { $comment_meta['academy_qa_topic_id'] = $topic_id; $comment_meta['academy_qa_topic_type'] = $topic_type; } $default_data = array( 'comment_content' => '', 'comment_post_ID' => '', 'comment_parent' => 0, 'comment_approved' => 'waiting_for_answer', 'comment_type' => 'academy_qa', 'user_id' => $current_user->ID, 'comment_author' => $current_user->user_login, 'comment_author_email' => $current_user->user_email, 'comment_author_url' => $current_user->user_url, 'comment_agent' => 'AcademyLMS', ); $comment_data = wp_parse_args( array( 'comment_post_ID' => $course_id, 'comment_parent' => $parent, 'comment_content' => $content, 'comment_approved' => $comment_approved, 'user_id' => $current_user->ID, 'comment_author' => $current_user->user_login, 'comment_author_email' => $current_user->user_email, 'comment_author_url' => $current_user->user_url, 'comment_meta' => $comment_meta, ), $default_data ); $comment_id = wp_insert_comment( $comment_data ); $comment = $this->prepare_comment_for_response( get_comment( $comment_id ) ); if ( 'waiting_for_answer' === $comment_approved ) { // insert question do_action( 'academy/frontend/insert_course_qa', $comment ); } elseif ( 'answered' === $comment_approved ) { // reply question do_action( 'academy/frontend/insert_course_qa_answered', $comment ); } wp_send_json_success( $comment ); }//end if wp_send_json_error( __( 'Sorry, you have not permission to create QA.', 'academy' ) ); } /** * A Q&A comment may only be managed by an administrator or by an instructor of * the course the comment belongs to. The bare `manage_academy_instructor` * capability alone is not scoped to ownership — without this check any * instructor could approve/edit or delete Q&A on courses owned by other * instructors. * * @param int $comment_ID Comment ID. * @return bool */ protected function current_user_can_manage_qa( $comment_ID ) { // Only Q&A comments: not reviews, completion records or other comments. $comment = get_comment( $comment_ID ); if ( ! $comment || 'academy_qa' !== $comment->comment_type ) { return false; } if ( current_user_can( 'manage_options' ) ) { return true; } if ( ! current_user_can( 'manage_academy_instructor' ) ) { return false; } return (bool) \Academy\Helper::is_instructor_of_this_course( get_current_user_id(), (int) $comment->comment_post_ID ); } public function update_qa() { check_ajax_referer( 'academy_nonce', 'security' ); $comment_ID = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0; if ( $this->current_user_can_manage_qa( $comment_ID ) ) { $comment_approved = isset( $_POST['status'] ) ? sanitize_text_field( wp_unslash( $_POST['status'] ) ) : ''; if ( ! in_array( $comment_approved, array( 'answered', 'waiting_for_answer' ), true ) ) { wp_send_json_error( __( 'Invalid status.', 'academy' ) ); } wp_update_comment( array( 'comment_ID' => $comment_ID, 'comment_approved' => $comment_approved, ) ); $comment = $this->prepare_comment_for_response( get_comment( $comment_ID ) ); wp_send_json_success( $comment ); } wp_send_json_error( __( 'Sorry, you have no permission to update QA.', 'academy' ) ); } public function delete_qa() { check_ajax_referer( 'academy_nonce', 'security' ); $comment_ID = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0; if ( $this->current_user_can_manage_qa( $comment_ID ) ) { $force = isset( $_POST['force'] ) && filter_var( sanitize_text_field( wp_unslash( $_POST['force'] ) ), FILTER_VALIDATE_BOOLEAN ); $comment = $this->prepare_comment_for_response( get_comment( $comment_ID ) ); $is_delete = wp_delete_comment( $comment_ID, $force ); wp_send_json_success([ 'previous' => $comment, 'status' => $is_delete ]); } wp_send_json_error( __( 'Sorry, you have no permission to delete QA.', 'academy' ) ); } public function add_x_wp_total_header( $response, $handler, $request ) { if ( '/' . $this->namespace . '/' . $this->rest_base === $request->get_route() ) { $total = 0; if ( ! current_user_can( 'manage_options' ) ) { $total = $this->get_total_number_of_qa( get_current_user_id() ); } else { $total = $this->get_total_number_of_qa(); } $response->header( 'x-wp-total', $total ); } return $response; } private function get_total_number_of_qa( $user_id = 0 ) { global $wpdb; // Base query $query = "SELECT COUNT(comment_ID) FROM {$wpdb->comments} WHERE comment_type = %s AND comment_agent = %s AND comment_parent = %d"; $query_params = array( 'academy_qa', 'AcademyLMS', 0 ); if ( $user_id ) { $course_ids = \Academy\Helper::get_course_ids_by_instructor_id( $user_id ); if ( ! empty( $course_ids ) ) { $placeholders = implode( ',', array_fill( 0, count( $course_ids ), '%d' ) ); $query .= " AND comment_post_ID IN ($placeholders)"; $query_params = array_merge( $query_params, $course_ids ); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared return $wpdb->get_var( $wpdb->prepare( $query, $query_params ) ); } public function academy_lesson_post_permalink( $permalink, $post = [] ) { if ( ! is_object( $post ) || 'academy_lessons' !== $post->post_type ) { return $permalink; } $comment_ids = get_comments( array( 'post_id' => $post->ID, 'fields' => 'ids', ) ); if ( empty( $comment_ids ) ) { return $permalink; } foreach ( $comment_ids as $comment_id ) { $course_id = (int) get_comment_meta( $comment_id, 'academy_comment_course_id', true ); if ( ! $course_id ) { continue; } // If PHP rendering is disabled if ( ! \Academy\Helper::is_server_learn_page() ) { $course_permalink = \Academy\Helper::get_start_course_permalink( $course_id ); return esc_url( $course_permalink ) . '#/lesson/' . $post->ID; } // If PHP rendering is enabled $topic = array( 'type' => 'lesson', 'slug' => $post->post_name ?? '', ); return \Academy\Helper::get_topic_play_link( $topic, $course_id ); }//end foreach return $permalink; } }