From 9a839e7c47d4612433ff3e6dfd910bc85514fc7f Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Wed, 22 Jul 2026 18:49:55 +0600 Subject: [PATCH] fix(relationships): implement post-trash dependency cleanup - Add course prerequisite unset on other courses when course deleted - Add course ID removal from membership auto-enroll arrays - Add child section force-deletion when course is deleted - Add lesson parent-course meta unset when course is deleted - Add section parent-section meta unset on lessons when section deleted - Add membership ID removal from post restriction arrays (_llms_restricted_levels) - Add membership ID removal from access plan availability restrictions (_llms_availability_restrictions) - Add sitewide membership option (lifterlms_membership_required) cleanup on membership deletion - Add remove_from_meta action type for serialized array meta cleanup - Add get_posts_with_meta_containing() helper using LIKE pre-filter - Add unit test coverage for all new cleanup paths Fixes #263 --- .../fix-263-post-trash-relationships.yml | 5 + includes/class.llms.post.relationships.php | 187 +++++++++++++++++- .../class-llms-test-post-relationships.php | 171 ++++++++++++++++ 3 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 .changelogs/fix-263-post-trash-relationships.yml diff --git a/.changelogs/fix-263-post-trash-relationships.yml b/.changelogs/fix-263-post-trash-relationships.yml new file mode 100644 index 0000000000..9829bbd9c1 --- /dev/null +++ b/.changelogs/fix-263-post-trash-relationships.yml @@ -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. diff --git a/includes/class.llms.post.relationships.php b/includes/class.llms.post.relationships.php index ca3ddfd8f5..9c5753afd7 100644 --- a/includes/class.llms.post.relationships.php +++ b/includes/class.llms.post.relationships.php @@ -5,7 +5,7 @@ * @package LifterLMS/Classes * * @since 3.16.12 - * @version 7.6.2 + * @version [version] */ defined( 'ABSPATH' ) || exit; @@ -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( @@ -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', @@ -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' ) ); @@ -383,6 +442,7 @@ 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 @@ -390,7 +450,7 @@ private function get_related_posts( $post_id, $post_type, $meta_key ) { 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; } @@ -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. * diff --git a/tests/phpunit/unit-tests/class-llms-test-post-relationships.php b/tests/phpunit/unit-tests/class-llms-test-post-relationships.php index eca091849e..8c45165d6b 100644 --- a/tests/phpunit/unit-tests/class-llms-test-post-relationships.php +++ b/tests/phpunit/unit-tests/class-llms-test-post-relationships.php @@ -183,11 +183,179 @@ function( $prop ) use ( $access_plan ) { } + /** + * When deleting courses, dependencies attached to it should be cleaned up: + * + * A) Prerequisites on other courses / lessons that point at this course should be removed. + * B) Auto-enrollment lists on memberships should drop the course ID. + * C) Child sections and their orphaned lessons should be forced-deleted or unlinked. + * + * @since [version] + * + * @return void + */ + private function delete_course() { + + $courses = $this->generate_mock_courses( 1, 1, 4, 3, 1 ); + $course_id = absint( $courses[0] ); + + // Create a course that references the mock course as prerequisite. + $other_course_id = $this->factory->post->create( array( 'post_type' => 'course' ) ); + $other_course = llms_get_post( $other_course_id ); + $other_course->set( 'has_prerequisite', 'yes' ); + $other_course->set( 'prerequisite', $course_id ); + + // Create a membership that auto-enrolls students into the mock course. + $membership_id = $this->factory->post->create( array( 'post_type' => 'llms_membership' ) ); + $membership = llms_get_post( $membership_id ); + $membership->add_auto_enroll_courses( array( $course_id ) ); + + $new_sections = array(); + for ( $i = 0; $i < 2; $i++ ) { + $new_sections[] = $this->factory->post->create( + array( + 'post_type' => 'section', + 'post_parent' => $course_id, + ) + ); + } + foreach ( $new_sections as $section_id ) { + update_post_meta( $section_id, '_llms_parent_course', $course_id ); + } + + wp_delete_post( $course_id ); + + $this->assertEmpty( get_post( $course_id ), 'Course should be force-deleted.' ); + + // Other course's prereq should have been cleared. + $this->assertFalse( $other_course->has_prerequisite() ); + $this->assertEquals( 0, $other_course->get( 'prerequisite' ) ); + + // Membership auto-enroll should have dropped the course ID. + $this->assertNotContains( $course_id, $membership->get_auto_enroll_courses() ); + + // Child sections should have been force-deleted. + foreach ( $new_sections as $section_id ) { + $this->assertNull( get_post( $section_id ), "Section {$section_id} should have been force-deleted." ); + } + } + + /** + * When deleting sections, parent_section meta on lessons in the section should be unset. + * + * @since [version] + * + * @return void + */ + private function delete_section() { + + // Build a simple course + section + lesson tree. + $courses = $this->generate_mock_courses( 1, 1, 1, 1, 1 ); + $course_id = absint( $courses[0] ); + + $sections = get_posts( + array( + 'post_type' => 'section', + 'meta_key' => '_llms_parent_course', + 'meta_value' => $course_id, + 'posts_per_page' => 1, + 'fields' => 'ids', + ) + ); + + $this->assertNotEmpty( $sections, 'Test fixture did not create a section.' ); + $section_id = absint( $sections[0] ); + $section = new LLMS_Section( $section_id ); + + $lesson_id = $section->get_lessons()[0]->get( 'id' ); + $lesson = llms_get_post( $lesson_id ); + + $this->assertEquals( $section_id, (int) get_post_meta( $lesson_id, '_llms_parent_section', true ), 'Pre-condition: lesson has a parent section.' ); + + wp_delete_post( $section_id ); + + $this->assertEquals( + 0, + (int) get_post_meta( $lesson_id, '_llms_parent_section', true ), + "Section delete should have unset the _llms_parent_section meta on the lesson." + ); + } + + /** + * When deleting a membership, restricted posts and availability restriction arrays should drop the membership ID. + * + * @since [version] + * + * @return void + */ + private function delete_membership_cleanups() { + + $membership_id = $this->factory->post->create( array( 'post_type' => 'llms_membership' ) ); + $membership = new LLMS_Membership( $membership_id ); + + // A page that references the membership in its restricted levels. + $page_id = $this->factory->post->create( array( 'post_type' => 'page' ) ); + update_post_meta( $page_id, '_llms_is_restricted', 'yes' ); + update_post_meta( $page_id, '_llms_restricted_levels', array( $membership_id ) ); + + // An access plan restricted to members of this membership. + $access_plan_id = llms_insert_access_plan( + array( + 'title' => 'Members only', + 'product_id' => $this->factory->post->create( array( 'post_type' => 'course' ) ), + 'availability' => 'members', + 'availability_restrictions' => array( $membership_id ), + ) + )->get( 'id' ); + + wp_delete_post( $membership_id ); + + // Restricted levels should no longer contain the membership ID. + $this->assertEmpty( get_post_meta( $page_id, '_llms_restricted_levels', true ) ); + + // Access plan restrictions should no longer reference this membership. + $plan_restriction = get_post_meta( $access_plan_id, '_llms_availability_restrictions', true ); + $this->assertEmpty( $plan_restriction ); + } + + /** + * When deleting the sitewide-required membership, the option that points at it should be cleared. + * + * @since [version] + * + * @return void + */ + public function test_maybe_clear_sitewide_membership_restriction() { + + $membership_id = $this->factory->post->create( array( 'post_type' => 'llms_membership' ) ); + + update_option( 'lifterlms_membership_required', $membership_id ); + + LLMS_Post_Relationships::maybe_clear_sitewide_membership_restriction( $membership_id ); + + $this->assertEmpty( get_option( 'lifterlms_membership_required' ) ); + + // Non-memberships should not clear the option. + update_option( 'lifterlms_membership_required', $membership_id ); + + LLMS_Post_Relationships::maybe_clear_sitewide_membership_restriction( $this->factory->post->create( array( 'post_type' => 'post' ) ) ); + + $this->assertNotEmpty( get_option( 'lifterlms_membership_required' ) ); + + // Unrelated membership should not clear the option. + update_option( 'lifterlms_membership_required', 999 ); + + LLMS_Post_Relationships::maybe_clear_sitewide_membership_restriction( $membership_id ); + + $this->assertEquals( 999, (int) get_option( 'lifterlms_membership_required' ) ); + } + /** * Test all relationships based on post types * * @since 3.16.12 * @since 4.15.0 Added tests on course on membership deletion. + * @since [version] Added tests for course prerequisites, section children, section lessons, and membership array metas. * * @return void */ @@ -197,6 +365,9 @@ public function test_maybe_update_relationships() { 'delete_quiz', 'delete_lesson', 'delete_product', + 'delete_course', + 'delete_section', + 'delete_membership_cleanups', ); foreach ( $funcs as $func ) { $this->{$func}();