Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelogs/fix-263-post-trash-relationships.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
significance: patch
type: fixed
links:
- "#263"
entry: Fixed stale prerequisite, restriction, and parent references left behind when courses, lessons, sections, or memberships are permanently deleted.
187 changes: 183 additions & 4 deletions includes/class.llms.post.relationships.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* @package LifterLMS/Classes
*
* @since 3.16.12
* @version 7.6.2
* @version [version]
*/

defined( 'ABSPATH' ) || exit;
Expand All @@ -17,31 +17,79 @@
* @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.
* @since [version] Clean up course prereqs, child sections, membership auto-enroll,
* section parent links, membership restriction arrays, and the
* sitewide membership option on deletion.
*/
class LLMS_Post_Relationships {

/**
* Configure relationships.
*
* Supported actions:
* - `delete` / `trash`: force-delete or trash related WP posts (or custom table rows).
* - `unset`: delete a scalar meta value equal to the deleted post ID.
* - `remove_from_meta`: remove the deleted post ID from a serialized array meta value.
*
* @since Unknown.
* @since 7.6.2 Added `llms_voucher` relationship.
* @since [version] Added course prereq/section/auto-enroll cleanup, section parent
* unset, and membership restriction/array cleanup.
* @var array
*/
private $relationships = array(
'course' => array(
array(
'action' => 'delete',
'meta_key' => '_llms_product_id',
'meta_key' => '_llms_product_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'post_type' => 'llms_access_plan',
),
// Other courses that list this course as a prerequisite.
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' => 'course',
),
// Memberships that auto-enroll students into this course.
array(
'action' => 'remove_from_meta',
'meta_key' => '_llms_auto_enroll', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'post_type' => 'llms_membership',
),
// Child sections of this course. Force-deleted (section has no trash support).
array(
'action' => 'delete',
'meta_key' => '_llms_parent_course', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'post_type' => 'section',
),
// Lessons that still point at this course as parent.
array(
'action' => 'unset',
'meta_key' => '_llms_parent_course', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'post_type' => 'lesson',
),
),

'llms_membership' => array(
array(
'action' => 'delete',
'meta_key' => '_llms_product_id',
'meta_key' => '_llms_product_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'post_type' => 'llms_access_plan',
),
// Access plans restricted to this membership.
array(
'action' => 'remove_from_meta',
'meta_key' => '_llms_availability_restrictions', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'post_type' => 'llms_access_plan',
),
// Posts restricted to this membership via the membership-restrictions feature.
// post_type is resolved at runtime via get_post_types_by_support().
array(
'action' => 'remove_from_meta',
'meta_key' => '_llms_restricted_levels', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'post_types_support' => 'llms-membership-restrictions',
),
),

'lesson' => array(
Expand All @@ -58,6 +106,15 @@ class LLMS_Post_Relationships {
),
),

// Bare post type is `section` (see LLMS_Section::$db_post_type).
'section' => array(
array(
'action' => 'unset',
'meta_key' => '_llms_parent_section', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'post_type' => 'lesson',
),
),

'llms_order' => array(
array(
'action' => 'delete',
Expand Down Expand Up @@ -101,12 +158,14 @@ class LLMS_Post_Relationships {
* @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.
* @since [version] Clear the sitewide membership restriction option on membership delete.
*
* @return void
*/
public function __construct() {

add_action( 'delete_post', array( $this, 'maybe_update_relationships' ) );
add_action( 'delete_post', array( __CLASS__, 'maybe_clear_sitewide_membership_restriction' ) );
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' ) );
Expand Down Expand Up @@ -383,14 +442,15 @@ private function get_related_posts( $post_id, $post_type, $meta_key ) {
*
* @since 3.16.12
* @since 3.24.0 Unknown.
* @since [version] Handle the `remove_from_meta` action for serialized array metas.
*
* @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 ) ) {
if ( ! $post || ! in_array( $post->post_type, $this->get_post_types(), true ) ) {
return;
}

Expand All @@ -410,11 +470,130 @@ public function maybe_update_relationships( $post_id ) {

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

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

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

}
}
}
}

/**
* Clear the sitewide membership restriction option when its membership is deleted.
*
* The option `lifterlms_membership_required` stores a single membership post ID.
*
* @since [version]
*
* @param int $post_id WP Post ID of the deleted post.
* @return void
*/
public static function maybe_clear_sitewide_membership_restriction( $post_id ) {

if ( 'llms_membership' !== get_post_type( $post_id ) ) {
return;
}

$option_id = absint( get_option( 'lifterlms_membership_required', '' ) );
if ( $option_id && (int) $option_id === (int) $post_id ) {
delete_option( 'lifterlms_membership_required' );
}
}

/**
* Remove a deleted post's ID from a serialized array stored in post meta.
*
* Used for membership auto-enroll lists, availability restrictions on access
* plans, and membership-restricted posts. The DB lookup is a LIKE pre-filter;
* the authoritative check is the PHP-side `in_array()` after unserializing.
*
* @since [version]
*
* @param WP_Post $post WP Post that's been deleted.
* @param array $data Relationship data array. Expected keys:
* - `meta_key` (string) Meta key holding the serialized array.
* - `post_type` (string, optional) Single target post type.
* - `post_types_support` (string, optional) Feature flag used
* with `get_post_types_by_support()` when multiple types apply.
* @return void
*/
private function remove_from_meta_relationships( $post, $data ) {

$post_types = array();
if ( ! empty( $data['post_type'] ) ) {
$post_types = array( $data['post_type'] );
} elseif ( ! empty( $data['post_types_support'] ) ) {
$post_types = get_post_types_by_support( $data['post_types_support'] );
}

if ( empty( $post_types ) || empty( $data['meta_key'] ) ) {
return;
}

foreach ( $post_types as $post_type ) {
$candidate_ids = $this->get_posts_with_meta_containing( $post->ID, $post_type, $data['meta_key'] );

foreach ( $candidate_ids as $id ) {
$value = get_post_meta( $id, $data['meta_key'], true );
if ( ! is_array( $value ) ) {
continue;
}

// Coerce to ints so string/int mismatches don't leave stale IDs behind.
$value = array_map( 'absint', $value );
$cleaned = array_values( array_diff( $value, array( (int) $post->ID ) ) );

if ( count( $cleaned ) === count( $value ) ) {
continue;
}

if ( empty( $cleaned ) ) {
delete_post_meta( $id, $data['meta_key'] );
} else {
update_post_meta( $id, $data['meta_key'], $cleaned );
}
}
}
}

/**
* Find posts of a type whose meta value (serialized array) may contain a post ID.
*
* Uses a LIKE pre-filter for speed; callers must still validate with
* `in_array()` after unserializing. See `remove_from_meta_relationships()`.
*
* @since [version]
*
* @param int $post_id Deleted post ID to search for inside the meta value.
* @param string $post_type Target post type.
* @param string $meta_key Meta key holding the serialized array.
* @return int[]
*/
private function get_posts_with_meta_containing( $post_id, $post_type, $meta_key ) {

global $wpdb;

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT p.ID
FROM {$wpdb->posts} AS p
INNER JOIN {$wpdb->postmeta} AS pm
ON p.ID = pm.post_id
WHERE p.post_type = %s
AND pm.meta_key = %s
AND pm.meta_value LIKE %s",
$post_type,
$meta_key,
'%' . $wpdb->esc_like( (string) $post_id ) . '%'
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

return array_map( 'absint', $ids );
}

/**
* Unsets relationship data from post_meta when a post is deleted.
*
Expand Down
Loading
Loading