diff --git a/inc/jwt/class-jwt-auth.php b/inc/jwt/class-jwt-auth.php index db0305374d..6f64980301 100644 --- a/inc/jwt/class-jwt-auth.php +++ b/inc/jwt/class-jwt-auth.php @@ -49,6 +49,7 @@ private function includes() { require_once LP_PLUGIN_PATH . 'inc/jwt/rest-api/version1/class-lp-rest-course-category-v1-controller.php'; require_once LP_PLUGIN_PATH . 'inc/jwt/rest-api/version1/class-lp-rest-sections-v1-controller.php'; require_once LP_PLUGIN_PATH . 'inc/jwt/rest-api/version1/class-lp-rest-section-items-v1-controller.php'; + require_once LP_PLUGIN_PATH . 'inc/jwt/rest-api/version1/class-lp-rest-checkout-v1-controller.php'; require_once LP_PLUGIN_PATH . 'inc/jwt/rest-api/lp-rest-function.php'; require_once LP_PLUGIN_PATH . 'inc/jwt/rest-api/class-rest-api.php'; diff --git a/inc/jwt/includes/class-jwt-public.php b/inc/jwt/includes/class-jwt-public.php index b9a11e2c95..a56000ca75 100644 --- a/inc/jwt/includes/class-jwt-public.php +++ b/inc/jwt/includes/class-jwt-public.php @@ -217,34 +217,44 @@ public function generate_token( WP_REST_Request $request ) { * @return (int|bool) */ public function determine_current_user( $user_id ) { - $rest_prefix = trailingslashit( rest_get_url_prefix() ); - $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); - $valid_api_uri = strpos( $request_uri, $rest_prefix . $this->name . '/' ); + if ( ! empty( $user_id ) ) { + return $user_id; + } + + $rest_prefix = trailingslashit( rest_get_url_prefix() ); + $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) ); /** - * Only check when rest url has wp-json/learnpress/. + * Only process REST requests. */ - if ( ! empty( $user_id ) || $valid_api_uri === false ) { + if ( strpos( $request_uri, $rest_prefix ) === false ) { return $user_id; } /* - * if the request URI is for validate the token don't do anything, - * this avoid double calls to the validate_token function. + * Skip the token endpoint itself to avoid double validation. */ - $validate_token = strpos( $request_uri, '/token' ); - - /** All course is public so donot need token */ - $is_rest_courses = strpos( $request_uri, '/courses' ) || strpos( $request_uri, '/reset-password' ) || strpos( $request_uri, '/course_category' ) || strpos( $request_uri, '/sections/' ) || strpos( $request_uri, '/section-items/' ) || strpos( $request_uri, '/users' ); + if ( strpos( $request_uri, $rest_prefix . $this->namespace . '/token' ) !== false ) { + return $user_id; + } - if ( $validate_token > 0 ) { + /* + * No Authorization header → let other auth methods (cookie, app passwords) handle it. + */ + $has_auth = ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) || ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ); + if ( ! $has_auth ) { return $user_id; } + /** Public LP endpoints that should not surface auth errors. */ + $is_public_lp = (bool) ( strpos( $request_uri, '/courses' ) || strpos( $request_uri, '/reset-password' ) || strpos( $request_uri, '/course_category' ) || strpos( $request_uri, '/sections/' ) || strpos( $request_uri, '/section-items/' ) || strpos( $request_uri, '/users' ) ); + + $is_lp_api = strpos( $request_uri, $rest_prefix . $this->name . '/' ) !== false; + $token = $this->validate_token( false ); if ( is_wp_error( $token ) ) { - if ( ! $is_rest_courses ) { + if ( $is_lp_api && ! $is_public_lp ) { $this->jwt_error = $token; } diff --git a/inc/jwt/rest-api/class-rest-api.php b/inc/jwt/rest-api/class-rest-api.php index f2836d6b4f..5a6724488a 100644 --- a/inc/jwt/rest-api/class-rest-api.php +++ b/inc/jwt/rest-api/class-rest-api.php @@ -43,6 +43,7 @@ protected function get_v1_controllers() { 'course_category' => 'LP_Jwt_Course_Category_V1_Controller', 'sections' => 'LP_Jwt_Sections_V1_Controller', 'section-items' => 'LP_Jwt_Section_Items_V1_Controller', + 'checkout' => 'LP_Jwt_Checkout_V1_Controller', ); } diff --git a/inc/jwt/rest-api/version1/class-lp-rest-checkout-v1-controller.php b/inc/jwt/rest-api/version1/class-lp-rest-checkout-v1-controller.php new file mode 100644 index 0000000000..5ef9fd86c8 --- /dev/null +++ b/inc/jwt/rest-api/version1/class-lp-rest-checkout-v1-controller.php @@ -0,0 +1,517 @@ +namespace, + '/payment-methods', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'list_payment_methods' ), + 'permission_callback' => '__return_true', + ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'process_checkout' ), + 'permission_callback' => array( $this, 'checkout_permissions_check' ), + 'args' => array( + 'course_id' => array( + 'required' => true, + 'type' => 'integer', + ), + 'payment_method' => array( + 'required' => false, + 'type' => 'string', + ), + 'notes' => array( + 'required' => false, + 'type' => 'string', + ), + ), + ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/paypal/create-order', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'paypal_create_order' ), + 'permission_callback' => array( $this, 'checkout_permissions_check' ), + 'args' => array( + 'course_id' => array( + 'required' => true, + 'type' => 'integer', + ), + 'notes' => array( + 'required' => false, + 'type' => 'string', + ), + ), + ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/paypal/capture', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'paypal_capture' ), + 'permission_callback' => array( $this, 'checkout_permissions_check' ), + 'args' => array( + 'paypal_order_id' => array( + 'required' => true, + 'type' => 'string', + ), + ), + ), + ) + ); + } + + public function checkout_permissions_check( $request ) { + if ( ! is_user_logged_in() ) { + return new WP_Error( + 'rest_forbidden', + esc_html__( 'You must be logged in to checkout.', 'learnpress' ), + array( 'status' => 401 ) + ); + } + + return true; + } + + /** + * List all enabled payment gateways. + */ + public function list_payment_methods( $request ) { + $response = new LP_REST_Response(); + $gateways = LP_Gateways::instance()->get_available_payment_gateways(); + + $methods = array(); + foreach ( $gateways as $gateway ) { + if ( ! is_object( $gateway ) ) { + continue; + } + + $method = array( + 'id' => $gateway->get_id(), + 'title' => $gateway->title ? wp_strip_all_tags( $gateway->title ) : $gateway->get_method_title(), + 'description' => $gateway->description ? wp_strip_all_tags( $gateway->description ) : $gateway->get_method_description(), + 'icon' => esc_url_raw( (string) $gateway->icon ), + 'is_default' => (bool) ( $gateway->is_selected ?? false ), + 'config' => new stdClass(), + ); + + // Expose PayPal env so the frontend can render Smart Buttons via @paypal/react-paypal-js. + if ( $gateway->get_id() === 'paypal' ) { + $paypal_settings = LP_Settings::instance()->get_group( 'paypal' ); + $method['config'] = array( + 'client_id' => (string) $paypal_settings->get( 'app_client_id', '' ), + 'sandbox' => $paypal_settings->get( 'paypal_sandbox', 'no' ) === 'yes', + 'currency' => learn_press_get_currency(), + ); + } + + $methods[] = $method; + } + + $response->status = 'success'; + $response->data = $methods; + + return rest_ensure_response( $response ); + } + + /** + * Create an order for the given course + payment method. + * + * Body: { course_id, payment_method?, notes? } + * Returns: { status, message, data: { order_id, redirect } } + */ + public function process_checkout( $request ) { + $response = new LP_REST_Response(); + + try { + $course_id = absint( $request['course_id'] ); + $payment_method_str = isset( $request['payment_method'] ) ? sanitize_text_field( $request['payment_method'] ) : ''; + $notes = isset( $request['notes'] ) ? sanitize_textarea_field( $request['notes'] ) : ''; + + if ( ! $course_id ) { + throw new Exception( esc_html__( 'Missing course_id.', 'learnpress' ) ); + } + + $course = learn_press_get_course( $course_id ); + if ( ! $course ) { + throw new Exception( esc_html__( 'Invalid course.', 'learnpress' ) ); + } + + $user_id = get_current_user_id(); + + // Check if already enrolled. + $userCourse = UserCourseModel::find( $user_id, $course_id, true ); + if ( $userCourse && $userCourse->get_status() ) { + throw new Exception( esc_html__( 'You are already enrolled in this course.', 'learnpress' ) ); + } + + $cart = LearnPress::instance()->cart; + $checkout = LP_Checkout::instance(); + + // Single-course checkout — reset cart to avoid stale items. + $cart->empty_cart(); + $cart_id = $cart->add_to_cart( $course_id, 1, array() ); + if ( ! $cart_id ) { + throw new Exception( esc_html__( 'Could not add the course to the cart.', 'learnpress' ) ); + } + + $checkout->payment_method_str = $payment_method_str; + $checkout->order_comment = $notes; + + $needs_payment = $cart->needs_payment(); + + if ( $needs_payment ) { + if ( ! $payment_method_str ) { + throw new Exception( esc_html__( 'No payment method selected.', 'learnpress' ) ); + } + + $available = LP_Gateways::instance()->get_available_payment_gateways(); + if ( ! isset( $available[ $payment_method_str ] ) ) { + throw new Exception( esc_html__( 'Invalid payment method.', 'learnpress' ) ); + } + + $checkout->payment_method = $available[ $payment_method_str ]; + } + + // Match LP core's pattern: reuse the pending order stashed in the LP + // session (`order_awaiting_payment`) to avoid creating orphans on retry. + $order_id = $this->reuse_or_create_order( $checkout, $course_id ); + + $redirect = ''; + $requires_redirect = false; + $payment_label = ''; + + if ( $checkout->payment_method instanceof LP_Gateway_Abstract ) { + $payment_label = $checkout->payment_method->get_title(); + $payment_result = $checkout->payment_method->process_payment( $order_id ); + + // Order status after process_payment: PENDING means the gateway needs an + // external redirect (e.g. PayPal/Stripe) to actually take payment. + // PROCESSING / COMPLETED means the gateway settled the order in our DB + // (e.g. offline, sandbox) and no external redirect is necessary. + $order_after = new LP_Order( $order_id ); + $requires_redirect = ( $order_after->get_status() === 'pending' ); + + if ( $requires_redirect && ! empty( $payment_result['redirect'] ) ) { + $redirect = $payment_result['redirect']; + } + } else { + // Free course — complete the order directly. No external redirect. + $order_free = new LP_Order( $order_id ); + $order_free->payment_complete(); + $payment_label = esc_html__( 'Free', 'learnpress' ); + } + + $cart->empty_cart(); + // Order has been settled (free/offline/sandbox). Drop the session pointer + // so the next checkout starts a fresh order. External redirects (PayPal + // browser flow) keep it so a retry can reuse the still-pending order. + if ( ! $requires_redirect ) { + $this->clear_awaiting_session(); + } + + $order = new LP_Order( $order_id ); + + $response->status = 'success'; + $response->message = esc_html__( 'Order created.', 'learnpress' ); + $response->data = new stdClass(); + $response->data->order_id = $order_id; + $response->data->redirect = $redirect; + $response->data->requires_redirect = $requires_redirect; + $response->data->order = $this->build_order_summary( $order, $payment_label ); + } catch ( Throwable $e ) { + $response->status = 'error'; + $response->message = $e->getMessage(); + } + + return rest_ensure_response( $response ); + } + + /** + * POST /checkout/paypal/create-order + * Creates a pending LP order + a PayPal v2 order, returns the PayPal order id for SDK use. + */ + public function paypal_create_order( $request ) { + $response = new LP_REST_Response(); + + try { + $course_id = absint( $request['course_id'] ); + $notes = isset( $request['notes'] ) ? sanitize_textarea_field( $request['notes'] ) : ''; + + if ( ! $course_id ) { + throw new Exception( esc_html__( 'Missing course_id.', 'learnpress' ) ); + } + + $course = learn_press_get_course( $course_id ); + if ( ! $course ) { + throw new Exception( esc_html__( 'Invalid course.', 'learnpress' ) ); + } + + $user_id = get_current_user_id(); + $userCourse = UserCourseModel::find( $user_id, $course_id, true ); + if ( $userCourse && $userCourse->get_status() ) { + throw new Exception( esc_html__( 'You are already enrolled in this course.', 'learnpress' ) ); + } + + $available = LP_Gateways::instance()->get_available_payment_gateways(); + if ( ! isset( $available['paypal'] ) ) { + throw new Exception( esc_html__( 'PayPal is not enabled.', 'learnpress' ) ); + } + $paypal_gateway = $available['paypal']; + + $cart = LearnPress::instance()->cart; + $checkout = LP_Checkout::instance(); + + $cart->empty_cart(); + $cart_id = $cart->add_to_cart( $course_id, 1, array() ); + if ( ! $cart_id ) { + throw new Exception( esc_html__( 'Could not add the course to the cart.', 'learnpress' ) ); + } + + $checkout->payment_method_str = 'paypal'; + $checkout->order_comment = $notes; + $checkout->payment_method = $paypal_gateway; + + // Same session-based reuse pattern as LP_Checkout::process_checkout. + $lp_order_id = $this->reuse_or_create_order( $checkout, $course_id ); + $lp_order = new LP_Order( $lp_order_id ); + + // Create PayPal order via v2 API directly so we can read the id (for SDK). + $data_token = $paypal_gateway->get_access_token(); + if ( ! isset( $data_token->access_token ) || ! isset( $data_token->token_type ) ) { + throw new Exception( esc_html__( 'Invalid PayPal access token.', 'learnpress' ) ); + } + + $params = $paypal_gateway->get_order_args( $lp_order ); + + $paypal_response = wp_remote_post( + $paypal_gateway->api_url . 'v2/checkout/orders', + array( + 'body' => json_encode( $params ), + 'headers' => array( + 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, + 'Content-Type' => 'application/json', + ), + 'timeout' => 60, + ) + ); + + $paypal_result = LP_Helper::json_decode( wp_remote_retrieve_body( $paypal_response ) ); + + if ( isset( $paypal_result->error ) ) { + throw new Exception( $paypal_result->error_description ?? 'PayPal error' ); + } + if ( isset( $paypal_result->name ) && isset( $paypal_result->details[0] ) ) { + throw new Exception( $paypal_result->details[0]->description ); + } + if ( empty( $paypal_result->id ) ) { + throw new Exception( esc_html__( 'Invalid PayPal order response.', 'learnpress' ) ); + } + + $cart->empty_cart(); + + $response->status = 'success'; + $response->data = new stdClass(); + $response->data->lp_order_id = $lp_order_id; + $response->data->paypal_order_id = $paypal_result->id; + } catch ( Throwable $e ) { + $response->status = 'error'; + $response->message = $e->getMessage(); + } + + return rest_ensure_response( $response ); + } + + /** + * POST /checkout/paypal/capture + * Captures an authorized PayPal order and marks the matching LP order completed. + */ + public function paypal_capture( $request ) { + $response = new LP_REST_Response(); + + try { + $paypal_order_id = sanitize_text_field( $request['paypal_order_id'] ); + if ( ! $paypal_order_id ) { + throw new Exception( esc_html__( 'Missing paypal_order_id.', 'learnpress' ) ); + } + + $available = LP_Gateways::instance()->get_available_payment_gateways(); + if ( ! isset( $available['paypal'] ) ) { + throw new Exception( esc_html__( 'PayPal is not enabled.', 'learnpress' ) ); + } + $paypal_gateway = $available['paypal']; + + $data_token_str = LP_Settings::get_option( 'paypal_token' ); + $data_token = json_decode( $data_token_str ); + if ( ! isset( $data_token->access_token ) || ! isset( $data_token->token_type ) ) { + $data_token = $paypal_gateway->get_access_token(); + } + + $capture_response = wp_remote_post( + $paypal_gateway->api_url . 'v2/checkout/orders/' . $paypal_order_id . '/capture', + array( + 'headers' => array( + 'Content-Type' => 'application/json', + 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, + ), + 'timeout' => 60, + ) + ); + + $code = wp_remote_retrieve_response_code( $capture_response ); + $body = wp_remote_retrieve_body( $capture_response ); + $transaction = LP_Helper::json_decode( $body ); + + if ( $code !== 201 || ! isset( $transaction->status ) || $transaction->status !== 'COMPLETED' ) { + $msg = isset( $transaction->details[0]->description ) + ? $transaction->details[0]->description + : esc_html__( 'Could not capture PayPal payment.', 'learnpress' ); + throw new Exception( $msg ); + } + + $lp_order_id = 0; + if ( isset( $transaction->purchase_units[0]->payments->captures[0]->custom_id ) ) { + $lp_order_id = absint( $transaction->purchase_units[0]->payments->captures[0]->custom_id ); + } + if ( ! $lp_order_id ) { + throw new Exception( esc_html__( 'Could not resolve order.', 'learnpress' ) ); + } + + $lp_order = new LP_Order( $lp_order_id ); + + // Defense in depth: only allow the LP order's owner to finalize it. + if ( (int) $lp_order->get_user_id() !== get_current_user_id() ) { + throw new Exception( esc_html__( 'You are not allowed to capture this order.', 'learnpress' ) ); + } + + $lp_order->update_status( LP_ORDER_COMPLETED ); + $this->clear_awaiting_session(); + + $response->status = 'success'; + $response->message = esc_html__( 'Payment captured.', 'learnpress' ); + $response->data = new stdClass(); + $response->data->order = $this->build_order_summary( $lp_order, $paypal_gateway->get_title() ); + } catch ( Throwable $e ) { + $response->status = 'error'; + $response->message = $e->getMessage(); + } + + return rest_ensure_response( $response ); + } + + /** + * Per-user meta key for tracking the in-flight LP order awaiting payment. + * + * LP core uses LP_Session for this. In a JWT/headless context the session + * isn't reliable (it inits at plugins_loaded before JWT auth fires, and the + * proxy strips cookies), so we use user_meta instead — same semantics, + * scoped per user, persistent across requests. + */ + private const AWAITING_META_KEY = '_lp_jwt_order_awaiting_payment'; + + private function reuse_or_create_order( LP_Checkout $checkout, int $course_id ): int { + $user_id = get_current_user_id(); + + $existing_id = (int) get_user_meta( $user_id, self::AWAITING_META_KEY, true ); + if ( $existing_id ) { + $existing = learn_press_get_order( $existing_id ); + if ( $existing && $existing->has_status( array( 'pending', 'failed', 'cancelled' ) ) ) { + $contains_course = false; + foreach ( (array) $existing->get_items() as $oi ) { + if ( isset( $oi['course_id'] ) && (int) $oi['course_id'] === $course_id ) { + $contains_course = true; + break; + } + } + if ( $contains_course ) { + // User may have switched payment method between attempts — sync it. + if ( $checkout->payment_method instanceof LP_Gateway_Abstract ) { + $existing->set_data( 'payment_method', $checkout->payment_method->get_id() ); + $existing->set_data( 'payment_method_title', $checkout->payment_method->get_title() ); + $existing->save(); + } + return $existing_id; + } + } + // Stale — clear so a fresh order is created. + delete_user_meta( $user_id, self::AWAITING_META_KEY ); + } + + $new_id = $checkout->create_order(); + if ( is_wp_error( $new_id ) ) { + throw new Exception( $new_id->get_error_message() ); + } + update_user_meta( $user_id, self::AWAITING_META_KEY, $new_id ); + return $new_id; + } + + /** + * Clear the awaiting-payment marker once an order has been settled. + */ + private function clear_awaiting_session(): void { + $user_id = get_current_user_id(); + if ( $user_id ) { + delete_user_meta( $user_id, self::AWAITING_META_KEY ); + } + } + + /** + * Build the order summary payload shared by /checkout and /checkout/paypal/capture. + */ + private function build_order_summary( LP_Order $order, string $payment_label ): array { + $status = $order->get_status(); + $items_raw = $order->get_items(); + $items_data = array(); + foreach ( (array) $items_raw as $oi ) { + $items_data[] = array( + 'name' => isset( $oi['name'] ) ? wp_strip_all_tags( (string) $oi['name'] ) : '', + 'course_id' => isset( $oi['course_id'] ) ? absint( $oi['course_id'] ) : 0, + 'total' => isset( $oi['total'] ) ? (float) $oi['total'] : 0, + ); + } + + return array( + 'id' => $order->get_id(), + 'number' => $order->get_order_number(), + 'status' => $status, + 'status_label' => LP_Order::get_status_label( $status ), + 'total' => (float) $order->get_total(), + 'total_formatted' => $order->get_formatted_order_total(), + 'subtotal' => (float) $order->get_subtotal(), + 'created_at' => $order->get_order_date( 'c' ), + 'payment_method' => $payment_label, + 'items' => $items_data, + ); + } +} diff --git a/inc/jwt/rest-api/version1/class-lp-rest-courses-v1-controller.php b/inc/jwt/rest-api/version1/class-lp-rest-courses-v1-controller.php index 0e8dc0b2d3..6b70cf25c4 100644 --- a/inc/jwt/rest-api/version1/class-lp-rest-courses-v1-controller.php +++ b/inc/jwt/rest-api/version1/class-lp-rest-courses-v1-controller.php @@ -458,6 +458,12 @@ public function get_courses( WP_REST_Request $request ) { $params = $this->convert_params_query_courses( $params ); Courses::handle_params_for_query_courses( $filter, $params ); + + // Filter by slug (post_name). + if ( ! empty( $params['slug'] ) ) { + $filter->post_name = is_array( $params['slug'] ) ? reset( $params['slug'] ) : $params['slug']; + } + $key_cache = 'api/' . md5( json_encode( $params ) ); $key_cache_total = $key_cache . '_total'; $key_cache_total_pages = $key_cache . '_total_pages'; @@ -542,10 +548,14 @@ public function prepare_struct_courses_response( $courses, $params ): array { continue; } - $courseObjPrepare = new stdClass(); - $courseObjPrepare->id = (int) $courseObj->ID ?? 0; - $courseObjPrepare->name = html_entity_decode( $course->get_title() ); - $courseObjPrepare->image = $course->get_image_url(); + $courseObjPrepare = new stdClass(); + $courseObjPrepare->id = (int) $courseObj->ID ?? 0; + $courseObjPrepare->name = html_entity_decode( $course->get_title() ); + $courseObjPrepare->slug = $courseObj->post_name ?? get_post_field( 'post_name', $courseObj->ID ); + $courseObjPrepare->permalink = get_permalink( $courseObj->ID ); + $courseObjPrepare->excerpt = $courseObj->post_excerpt ?? ''; + $courseObjPrepare->count_students = method_exists( $course, 'count_students' ) ? (int) $course->count_students() : 0; + $courseObjPrepare->image = $course->get_image_url(); $author = $course->get_author_model(); $courseObjPrepare->instructor = ! empty( $author ) ? $this->get_author_info( $author ) : []; $duration = $course->get_meta_value_by_key( CoursePostModel::META_KEY_DURATION, '' ); @@ -730,6 +740,11 @@ public function prepare_object_for_response( $object, $request ) { $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->get_course_data( $object, $context, $request ); + // Ensure sections are always included for single-item response, regardless of schema/context filtering. + if ( empty( $request['optimize'] ) && ! isset( $data['sections'] ) ) { + $data['sections'] = $this->get_all_items( $object ); + } + $response = rest_ensure_response( $data ); return apply_filters( "lp_jwt_rest_prepare_{$this->post_type}_object", $response, $object, $request ); @@ -1138,6 +1153,7 @@ public function get_all_items( $course ): array { $data_item[] = array( 'id' => $item->get_id(), 'type' => $item->get_item_type(), + 'slug' => $post->post_name, 'title' => $post->post_title, 'preview' => $item->is_preview(), 'duration' => $item->get_duration()->to_timer( $format, true ), @@ -1572,6 +1588,11 @@ public function get_item_schema() { 'type' => 'string', 'context' => array( 'view', 'edit' ), ), + 'slug' => array( + 'description' => __( 'Item slug.', 'learnpress' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + ), 'title' => array( 'description' => __( 'Item title.', 'learnpress' ), 'type' => 'string', diff --git a/inc/jwt/rest-api/version1/class-lp-rest-lessons-v1-controller.php b/inc/jwt/rest-api/version1/class-lp-rest-lessons-v1-controller.php index 618468f3c3..a903401300 100644 --- a/inc/jwt/rest-api/version1/class-lp-rest-lessons-v1-controller.php +++ b/inc/jwt/rest-api/version1/class-lp-rest-lessons-v1-controller.php @@ -2,6 +2,7 @@ use LearnPress\Models\CourseModel; use LearnPress\Models\LessonPostModel; +use LearnPress\Models\UserItems\UserCourseModel; use LearnPress\Models\UserItems\UserLessonModel; use LearnPress\Models\UserModel; @@ -81,6 +82,15 @@ public function register_routes() { ); } + /** + * Allow guests/unauthenticated users to query the list endpoint (filtered by slug). + * Per-item visibility is still enforced by check_read_permission, which only + * allows preview items for guests. + */ + public function get_items_permissions_check( $request ) { + return true; + } + /** * Checks if a course can be read. * @@ -110,6 +120,18 @@ public function check_read_permission( $post_id ) { return false; } + // Allow guests and unenrolled users to read items marked as preview. + $course_id = $this->get_course_by_item_id( $post_id ); + if ( $course_id ) { + $course = learn_press_get_course( $course_id ); + if ( $course ) { + $item = $course->get_item( $post_id ); + if ( $item && $item->is_preview() ) { + return true; + } + } + } + $user_id = get_current_user_id(); if ( ! $user_id ) { @@ -118,9 +140,6 @@ public function check_read_permission( $post_id ) { $user = learn_press_get_user( $user_id ); - // Get course ID by lesson ID assigned. - $course_id = $this->get_course_by_item_id( $post_id ); - if ( empty( $course_id ) ) { return false; } @@ -230,11 +249,56 @@ public function finish_lesson( $request ) { LP_COURSE_CPT, true ); + + // Auto-create user lesson item if user is enrolled but never opened the lesson. if ( ! $userLessonModel instanceof UserLessonModel ) { - throw new Exception( __( 'You have not started lesson', 'learnpress' ) ); + $userCourseModel = UserCourseModel::find( get_current_user_id(), $course_id, true ); + if ( ! $userCourseModel instanceof UserCourseModel || ! $userCourseModel->has_enrolled_or_finished() ) { + throw new Exception( __( 'You must enroll the course first.', 'learnpress' ) ); + } + + $userLessonModel = new UserLessonModel(); + $userLessonModel->user_id = get_current_user_id(); + $userLessonModel->item_id = $id; + $userLessonModel->item_type = $lessonModel->post_type; + $userLessonModel->ref_id = $course_id; + $userLessonModel->ref_type = LP_COURSE_CPT; + $userLessonModel->parent_id = $userCourseModel->get_user_item_id(); + $userLessonModel->status = LP_ITEM_STARTED; + $userLessonModel->save(); + } elseif ( empty( $userLessonModel->parent_id ) ) { + // Repair legacy rows that were created without parent_id — without it, the lesson + // won't be counted by UserCourseModel::count_items_completed(). + $userCourseModel = UserCourseModel::find( get_current_user_id(), $course_id, true ); + if ( $userCourseModel instanceof UserCourseModel ) { + $userLessonModel->parent_id = $userCourseModel->get_user_item_id(); + $userLessonModel->save(); + } } $userLessonModel->set_complete(); + + // Recalculate course progress and persist to learnpress_user_item_results. + $userCourseModel = $userLessonModel->get_user_course_model(); + if ( $userCourseModel instanceof UserCourseModel ) { + $course_results = $userCourseModel->calculate_course_results( true ); + LP_User_Items_Result_DB::instance()->update( + $userCourseModel->get_user_item_id(), + wp_json_encode( $course_results ) + ); + + // Auto-finish the course when evaluation criteria are met. + $can_finish = $userCourseModel->can_finish(); + if ( $can_finish === true && ! is_wp_error( $can_finish ) ) { + try { + $userCourseModel->set_finish(); + } catch ( Throwable $finishError ) { + // Non-fatal — user can still click Finish manually. + error_log( __METHOD__ . ' auto-finish: ' . $finishError->getMessage() ); + } + } + } + $response->status = 'success'; $response->message = esc_html__( 'Congrats! You have completed the lesson successfully', 'learnpress' ); } catch ( Throwable $th ) { diff --git a/inc/jwt/rest-api/version1/class-lp-rest-quiz-v1-controller.php b/inc/jwt/rest-api/version1/class-lp-rest-quiz-v1-controller.php index 265316eac9..6261d8a0c8 100644 --- a/inc/jwt/rest-api/version1/class-lp-rest-quiz-v1-controller.php +++ b/inc/jwt/rest-api/version1/class-lp-rest-quiz-v1-controller.php @@ -112,6 +112,15 @@ public function register_routes() { ); } + /** + * Allow guests/unauthenticated users to query the list endpoint (filtered by slug). + * Per-item visibility is still enforced by check_read_permission, which only + * allows preview items for guests. + */ + public function get_items_permissions_check( $request ) { + return true; + } + /** * Checks if a course can be read. * @@ -141,6 +150,18 @@ public function check_read_permission( $post_id ) { return false; } + // Allow guests and unenrolled users to read items marked as preview. + $course_id = $this->get_course_by_item_id( $post_id ); + if ( $course_id ) { + $course = learn_press_get_course( $course_id ); + if ( $course ) { + $item = $course->get_item( $post_id ); + if ( $item && $item->is_preview() ) { + return true; + } + } + } + $user_id = get_current_user_id(); if ( ! $user_id ) { @@ -149,9 +170,6 @@ public function check_read_permission( $post_id ) { $user = learn_press_get_user( $user_id ); - // Get course ID by lesson ID assigned. - $course_id = $this->get_course_by_item_id( $post_id ); - if ( empty( $course_id ) ) { return false; } @@ -472,12 +490,16 @@ public function get_quiz_results( $quiz, $get_question = false ) { $duration = $quiz->get_duration(); + // Plugin's get_pagination() returns 0 when value <= 1 (treats 1 as "no pagination"). + // Override here so admins can set 1 question per page for step-by-step quizzes. + $pagination_raw = absint( get_post_meta( $quiz->get_id(), '_lp_pagination', true ) ); + $array = array( 'passing_grade' => $quiz->get_passing_grade(), 'negative_marking' => $quiz->get_negative_marking(), 'instant_check' => $quiz->get_instant_check(), 'retake_count' => (float) $quiz->get_retake_count(), - 'questions_per_page' => $quiz->get_pagination(), + 'questions_per_page' => $pagination_raw > 0 ? $pagination_raw : 0, 'page_numbers' => get_post_meta( $quiz->get_id(), '_lp_pagination_numbers', true ) === 'yes', 'review_questions' => $quiz->get_review_questions(), 'support_options' => learn_press_get_question_support_answer_options(), @@ -496,6 +518,74 @@ public function get_quiz_results( $quiz, $get_question = false ) { ) ); + // Post-process FIB questions: the actual question content (with [fib] shortcodes) + // is stored in lp_question_answers.title — NOT post_content. Read it from there, + // strip `fill=` answers, replace shortcodes with {{FIB_}} placeholders for + // the client. On reveal, attach `fib_answers` with per-blank correctness. + foreach ( $questions as &$q ) { + if ( ! isset( $q['type'] ) || $q['type'] !== 'fill_in_blanks' ) { + continue; + } + + $question_obj = $q['object'] ?? null; + if ( ! $question_obj || ! method_exists( $question_obj, 'get_data' ) ) { + continue; + } + + $question_id = $q['id'] ?? 0; + $answer_options = $question_obj->get_data( 'answer_options' ); + if ( empty( $answer_options ) || ! is_array( $answer_options ) ) { + continue; + } + + $first_answer = reset( $answer_options ); + $raw_content = $first_answer['title'] ?? ''; + $answer_id = $first_answer['question_answer_id'] ?? 0; + $user_answer = $answered[ $question_id ]['answered'] ?? ''; + $revealed = $show_check || $status === 'completed'; + + $q['content'] = apply_filters( + 'learn-press/question/fib/regex-content', + $raw_content, + $answer_id, + $revealed, + $user_answer + ); + + // FIB has no answer "options" in radio/checkbox sense; clear them. + $q['options'] = array(); + + if ( $revealed && method_exists( $question_obj, 'get_answer_data' ) ) { + $q['fib_answers'] = $question_obj->get_answer_data( + $raw_content, + $answer_id, + $user_answer + ); + } + } + unset( $q ); + + // Plugin's `learn_press_rest_prepare_user_questions()` only emits `explanation` + // when the user explicitly triggered instant_check on a question (it reads + // `$args['status']` which is never set in the defaults). For completed quizzes + // fill in explanations here so clients can render them on the review screen. + if ( $status === 'completed' ) { + foreach ( $questions as &$q ) { + if ( ! empty( $q['explanation'] ) ) { + continue; + } + $question_obj = $q['object'] ?? null; + if ( $question_obj && method_exists( $question_obj, 'get_explanation' ) ) { + $explanation = $question_obj->get_explanation(); + if ( $explanation ) { + $q['explanation'] = $explanation; + $q['has_explanation'] = true; + } + } + } + unset( $q ); + } + $output['questions'] = $questions; }