gocodebox/lifterlms

View on GitHub
includes/class.llms.post.relationships.php

Summary

Maintainability
B
4 hrs
Test Coverage
A
96%
<?php
/**
 * Define post and record relationships to automate cleanup of information when posts are deleted from the DB.
 *
 * @package LifterLMS/Classes
 *
 * @since 3.16.12
 * @version 6.0.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Hooks and actions related to post relationships.
 *
 * @since 3.16.12
 * @since 3.24.0 Unknown.
 * @since 3.37.8 Delete student quiz attempts when a quiz is deleted.
 * @since 4.15.0 Delete access plans related to courses/memberships on their deletion.
 */
class LLMS_Post_Relationships {

    /**
     * Configure relationships
     *
     * @var array
     */
    private $relationships = array(
        'course'          => array(
            array(
                'action'    => 'delete',
                'meta_key'  => '_llms_product_id',
                'post_type' => 'llms_access_plan',
            ),
        ),

        'llms_membership' => array(
            array(
                'action'    => 'delete',
                'meta_key'  => '_llms_product_id',
                'post_type' => 'llms_access_plan',
            ),
        ),

        'lesson'          => array(
            array(
                'action'               => 'unset',
                'meta_key'             => '_llms_prerequisite', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
                'meta_keys_additional' => array( '_llms_has_prerequisite' ),
                'post_type'            => 'lesson',
            ),
            array(
                'action'    => 'unset',
                'meta_key'  => '_llms_lesson_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
                'post_type' => 'llms_quiz',
            ),
        ),

        'llms_order'      => array(
            array(
                'action'    => 'delete',
                'meta_key'  => '_llms_order_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
                'post_type' => 'llms_transaction',
            ),
        ),

        'llms_quiz'       => array(
            array(
                'action'    => 'delete', // Delete = force delete; trash = move to trash.
                'meta_key'  => '_llms_parent_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
                'post_type' => 'llms_question',
            ),
            array(
                'action'               => 'unset',
                'meta_key'             => '_llms_quiz', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
                'meta_keys_additional' => array( '_llms_quiz_enabled' ),
                'post_type'            => 'lesson',
            ),
            array(
                'action'     => 'delete',
                'table_name' => 'lifterlms_quiz_attempts',
                'table_key'  => 'quiz_id',
            ),
        ),

    );

    /**
     * Constructor.
     *
     * @since 3.16.12
     * @since 5.4.0 Prevent course/membership with active subscriptions deletion.
     * @since 6.0.0 Added hook to cleanup user post meta data when awarded certs and achievements are deleted.
     *
     * @return void
     */
    public function __construct() {

        add_action( 'delete_post', array( $this, 'maybe_update_relationships' ) );
        add_action( 'pre_delete_post', array( __CLASS__, 'maybe_prevent_product_deletion' ), 10, 2 );

        add_action( 'before_delete_post', array( __CLASS__, 'maybe_clean_earned_engagments_related_user_post_meta' ) );

    }

    /**
     * Maybe delete LifterLMS user post meta related to earned engagements.
     *
     * @since 6.0.0
     *
     * @param int $post_id Post ID.
     * @return void
     */
    public static function maybe_clean_earned_engagments_related_user_post_meta( $post_id ) {

        $post_types = array(
            'llms_my_certificate',
            'llms_my_achievement',
        );
        $post_type  = get_post_type( $post_id );

        if ( ! in_array( $post_type, $post_types, true ) ) {
            return;
        }

        $earned_engagement = 'llms_my_certificate' === $post_type ? new LLMS_User_Certificate( $post_id ) : new LLMS_User_Achievement( $post_id );

        do_action_deprecated(
            'llms_before_delete_' . str_replace( 'llms_my_', '', $post_type ),
            array(
                $earned_engagement,
            ),
            '6.0.0',
            '',
            __( 'Use WordPress core  `before_delete_post` action hook', 'lifterlms' )
        );

        global $wpdb;
        $wpdb->delete(
            "{$wpdb->prefix}lifterlms_user_postmeta",
            array(
                'user_id'    => $earned_engagement->get_user_id(),
                'meta_key'   => '_' . str_replace( 'llms_my_', '', $post_type ) . '_earned',
                'meta_value' => $post_id,
            ),
            array( '%d', '%s', '%d' )
        ); // no-cache ok.

        add_action(
            'after_delete_post',
            function( $post_id ) use ( $earned_engagement, $post_type ) {

                if ( $earned_engagement->get( 'id' ) === $post_id ) {
                    do_action_deprecated(
                        'llms_delete_' . str_replace( 'llms_my_', '', $post_type ),
                        array(
                            $earned_engagement,
                        ),
                        '6.0.0',
                        '',
                        __( 'Use WordPress core `deleted_post` action hook.', 'lifterlms' )
                    );
                }

            }
        );
    }

    /**
     * Determine whether a product deletion should take place.
     *
     * @since 5.4.0
     *
     * @param bool|null $delete Whether to go forward with deletion.
     * @param WP_Post   $post   Post object.
     * @return bool|null
     */
    public static function maybe_prevent_product_deletion( $delete, $post ) {

        if ( ! in_array( get_post_type( $post ), array( 'course', 'llms_membership' ), true ) ) {
            return $delete;
        }

        $product = llms_get_product( $post );

        if ( empty( $product ) || ! $product->has_active_subscriptions() ) {
            return $delete;
        }

        // If performing the deletion via REST API change the error message to reflect the reason for the prevention.
        if ( llms_is_rest() ) {
            // Filter the error message.
            add_filter( 'rest_request_after_callbacks', array( __CLASS__, 'rest_filter_products_with_active_subscriptions_error_message' ), 10, 3 );
        } else { // Deleting via wp-admin.
            wp_die(
                self::delete_product_with_active_subscriptions_error_message( $product->get( 'id' ) )
            );
        }

        return false;

    }

    /**
     * Filter the error message returned when trying to delete a product with active subscription via REST API.
     *
     * The original message is a standard permission denied message.
     *
     * @since 5.4.0
     *
     * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client.
     *                                                                   Usually a WP_REST_Response or WP_Error.
     * @param array                                            $handler  Route handler used for the request.
     * @param WP_REST_Request                                  $request  Request used to generate the response.
     * @return WP_REST_Response|WP_HTTP_Response|WP_Error|mixed
     */
    public static function rest_filter_products_with_active_subscriptions_error_message( $response, $handler, $request ) {

        if ( is_wp_error( $response ) ) {
            foreach ( $response->errors as $code => &$data ) {
                // Error code can be produced by our rest-api or by wp core.
                if ( in_array( $code, array( 'llms_rest_cannot_delete', 'rest_cannot_delete' ), true ) ) {
                    $data[0] = self::delete_product_with_active_subscriptions_error_message( $request['id'] );
                    break;
                }
            }
        }

        return $response;

    }

    /**
     * Returns the error message to display when deleting a product with active subscriptions.
     *
     * @since 5.4.0
     *
     * @param int $post_id The WP_Post ID of the product.
     * @return string
     */
    public static function delete_product_with_active_subscriptions_error_message( $post_id ) {

        $post_type = get_post_type( $post_id );

        if ( ! in_array( $post_type, array( 'course', 'llms_membership' ), true ) ) {
            return '';
        }

        $post_type_object = get_post_type_object( $post_type );
        $post_type_name   = $post_type_object->labels->name;
        return sprintf(
            // Translators: %s = The post type plural name.
            __( 'Sorry, you are not allowed to delete %s with active subscriptions.', 'lifterlms' ),
            $post_type_name
        );

    }

    /**
     * Delete / Trash posts related to the deleted post.
     *
     * @since 3.16.12
     * @since 3.37.8 Allow for deletion of related items outside the WP core posts table.
     *
     * @param WP_Post $post WP Post that's been deleted.
     * @param array   $data Relationship data array.
     * @return void
     */
    private function delete_relationships( $post, $data ) {

        if ( isset( $data['post_type'] ) && isset( $data['meta_key'] ) ) {

            $this->delete_wp_posts( $post, $data );

        } elseif ( isset( $data['table_name'] ) && isset( $data['table_key'] ) ) {

            $this->delete_table_records( $post, $data );

        }

    }

    /**
     * Delete records from a table that are related to the deleted post.
     *
     * @since 3.37.8
     *
     * @param WP_Post $post WP Post that's been deleted.
     * @param array   $data Relationship data array.
     * @return void
     */
    private function delete_table_records( $post, $data ) {

        global $wpdb;
        $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prefix . $data['table_name'],
            array(
                $data['table_key'] => $post->ID,
            ),
            '%d'
        );

    }

    /**
     * Delete or trash WP Posts related to the deleted post.
     *
     * @since 3.37.8
     *
     * @param WP_Post $post WP Post that's been deleted.
     * @param array   $data Relationship data array.
     * @return void
     */
    private function delete_wp_posts( $post, $data ) {

        $relationships = $this->get_related_posts( $post->ID, $data['post_type'], $data['meta_key'] );

        $force = ( 'delete' === $data['action'] );

        foreach ( $relationships as $id ) {
            wp_delete_post( $id, $force );
        }

    }

    /**
     * Get a list of post types with relationships that should be checked.
     *
     * @since 3.16.12
     *
     * @return array
     */
    private function get_post_types() {
        return array_keys( $this->get_relationships() );
    }

    /**
     * Retrieve filtered LifterLMS post relationships array.
     *
     * @since 3.16.12
     *
     * @return array
     */
    private function get_relationships() {
        return apply_filters( 'llms_get_post_relationships', $this->relationships );
    }

    /**
     * Retrieve an array of post ids related to the deleted post by post type and meta key.
     *
     * @since 3.16.12
     *
     * @param int    $post_id   WP Post ID of the deleted post.
     * @param string $post_type WP Post type of the related post(s).
     * @param string $meta_key  meta_key to check for relations by.
     * @return array
     */
    private function get_related_posts( $post_id, $post_type, $meta_key ) {

        global $wpdb;
        return $wpdb->get_col(
            $wpdb->prepare(
                "SELECT p.ID
             FROM {$wpdb->posts} AS p
             LEFT JOIN {$wpdb->postmeta} AS pm
                    ON p.ID = pm.post_id
                   AND pm.meta_key = %s
             WHERE p.post_type = %s
               AND pm.meta_value = %d",
                $meta_key,
                $post_type,
                $post_id
            )
        ); // db-call ok; no-cache ok.

    }

    /**
     * Check relationships and delete / update related posts when a post is deleted.
     *
     * Called on `delete_post` hook (before a post is deleted).
     *
     * @since 3.16.12
     * @since 3.24.0 Unknown.
     *
     * @param int $post_id WP Post ID of the deleted post.
     * @return void
     */
    public function maybe_update_relationships( $post_id ) {

        $post = get_post( $post_id );
        if ( ! in_array( $post->post_type, $this->get_post_types(), true ) ) {
            return;
        }

        foreach ( $this->get_relationships() as $post_type => $relationships ) {

            if ( $post->post_type !== $post_type ) {
                continue;
            }

            foreach ( $relationships as $data ) {

                if ( in_array( $data['action'], array( 'delete', 'trash' ), true ) ) {

                    $this->delete_relationships( $post, $data );

                } elseif ( 'unset' === $data['action'] ) {

                    $this->unset_relationships( $post, $data );

                }
            }
        }

    }

    /**
     * Unsets relationship data from post_meta when a post is deleted.
     *
     * @since 3.16.12
     * @since 3.24.0 Unknown.
     *
     * @param WP_Post $post WP Post that's been deleted.
     * @param array   $data Relationship data array.
     * @return void
     */
    private function unset_relationships( $post, $data ) {

        $relationships = $this->get_related_posts( $post->ID, $data['post_type'], $data['meta_key'] );

        foreach ( $relationships as $id ) {

            delete_post_meta( $id, $data['meta_key'], $post->ID );

            if ( isset( $data['meta_keys_additional'] ) ) {
                foreach ( $data['meta_keys_additional'] as $key ) {
                    delete_post_meta( $id, $key );
                }
            }
        }

    }

}

return new LLMS_Post_Relationships();