get_membership_paid_course_ids(); if ( $wants_free ) { $query['args']['post__not_in'] = $membership_ids; return $query; } // Paid: courses saved as paid (or another selected type) plus the // membership ones, which no meta clause can express together. $other_ids = get_posts( [ 'post_type' => 'academy_courses', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'no_found_rows' => true, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => $query['meta'], ] ); $query['meta'] = []; $post_in = array_map( 'intval', array_merge( $other_ids, $membership_ids ) ); $query['args']['post__in'] = $post_in ? $post_in : [ 0 ]; return $query; } /** * Courses saved as free that a StoreEngine membership makes paid for the * current visitor (a member who already holds it still sees them as free). * * @return int[] */ protected function get_membership_paid_course_ids(): array { $free_ids = get_posts( [ 'post_type' => 'academy_courses', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'no_found_rows' => true, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'meta_key' => 'academy_course_type', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'meta_value' => 'free', ] ); $ids = []; foreach ( $free_ids as $course_id ) { if ( 'paid' === Helper::get_course_type( (int) $course_id ) ) { $ids[] = (int) $course_id; } } return $ids; } /** * Enroll a user in every course the membership group grants. * * @param int $user_id * @param int $group_id */ public function enroll_group_courses( $user_id, $group_id ) { $user_id = (int) $user_id; if ( ! $user_id ) { return; } foreach ( $this->get_group_course_ids( (int) $group_id ) as $course_id ) { // do_enroll() is a no-op when the user is already actively enrolled. Helper::do_enroll( $course_id, $user_id ); } // Restore enrollments cancel_group_courses() cancelled for courses the // group protects by its content rules, so a re-grant undoes a revoke // or expiry. Progress is kept since the record was never deleted. foreach ( $this->get_protected_enrolled_course_ids( $user_id, (int) $group_id, 'cancel' ) as $course_id ) { $enrolled = $this->get_enrollment( $course_id, $user_id ); if ( $enrolled ) { Helper::update_enrollment_status( $course_id, $enrolled->ID, $user_id, 'completed' ); clean_post_cache( $enrolled->ID ); } } } /** * Cancel the enrollments the membership granted when the user leaves it. * * Enrollment is cancelled (not deleted) so the student's progress survives * if they rejoin — mirroring how Academy handles a cancelled enrollment. * * @param int $user_id * @param int $group_id */ public function cancel_group_courses( $user_id, $group_id ) { $user_id = (int) $user_id; if ( ! $user_id ) { return; } $course_ids = array_unique( array_merge( $this->get_group_course_ids( (int) $group_id ), $this->get_protected_enrolled_course_ids( $user_id, (int) $group_id ) ) ); foreach ( $course_ids as $course_id ) { $enrolled = $this->get_enrollment( $course_id, $user_id ); if ( empty( $enrolled ) || 'cancel' === $enrolled->enrolled_status ) { continue; } // Bought on its own (Academy records the order on direct // purchases), so the membership was never the access grant. if ( get_post_meta( $enrolled->ID, 'academy_enrolled_by_order_id', true ) ) { continue; } // Another group the user still holds keeps the course open. if ( $this->has_other_group_access( $user_id, $course_id, (int) $group_id ) ) { continue; } Helper::update_enrollment_status( $course_id, $enrolled->ID, $user_id, 'cancel' ); clean_post_cache( $enrolled->ID ); } } /** * The user's enrollment record for a course, in any status. * * Helper::is_enrolled() returns false whenever nobody is logged in, which * is always the case on the cron sweep that fires membership expiry. * * @param int $course_id * @param int $user_id * * @return object|null {ID, enrolled_status} */ protected function get_enrollment( int $course_id, int $user_id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return $wpdb->get_row( $wpdb->prepare( "SELECT ID, post_status AS enrolled_status FROM {$wpdb->posts} WHERE post_type = 'academy_enrolled' AND post_parent = %d AND post_author = %d LIMIT 1", $course_id, $user_id ) ); } /** * Courses the user is enrolled in that the group protects through its own * content rules (specific course, course taxonomy term, all courses...). * * Groups usually link courses this way rather than through the course's * `academy_store_membership` meta, so the reverse meta lookup alone misses * them. * * @param int $user_id * @param int $group_id * @param string $status 'active' (anything but cancelled) or 'cancel'. * * @return int[] */ protected function get_protected_enrolled_course_ids( int $user_id, int $group_id, string $status = 'active' ): array { if ( ! $group_id ) { return []; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $enrolled_course_ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_parent FROM {$wpdb->posts} WHERE post_type = 'academy_enrolled' AND post_author = %d AND ( post_status = 'cancel' ) = %d", $user_id, 'cancel' === $status ? 1 : 0 ) ); return array_values( array_filter( array_map( 'intval', $enrolled_course_ids ), fn( $course_id ) => $this->group_protects_course( $group_id, $course_id ) ) ); } /** * Whether a group's content rules cover a course. * * Reads the group's saved rules directly rather than going through * StoreEngine's HelperAddon::get_all_plans(), which matches against the * current request's query and so gives wrong answers from cron or early * hooks (and fatals in admin before get_current_screen() is loaded). * * @param int $group_id * @param int $course_id */ protected function group_protects_course( int $group_id, int $course_id ): bool { $protect = get_post_meta( $group_id, '_storeengine_membership_content_protect_types', true ); if ( ! is_array( $protect ) ) { return false; } $keys = $this->get_course_rule_keys( $course_id ); $excluded = get_post_meta( $group_id, '_storeengine_membership_content_protect_excluded_items', true ); foreach ( is_array( $excluded ) ? $excluded : [] as $item ) { if ( in_array( $item['value'] ?? '', $keys['specifics'], true ) ) { return false; } } if ( array_intersect( (array) ( $protect['rules'] ?? [] ), $keys['rules'] ) ) { return true; } foreach ( (array) ( $protect['specifics'] ?? [] ) as $item ) { if ( in_array( $item['value'] ?? '', $keys['specifics'], true ) ) { return true; } } return false; } /** * Rule values StoreEngine uses to target a single course. * * @param int $course_id * * @return array{rules:string[],specifics:string[]} */ protected function get_course_rule_keys( int $course_id ): array { $specifics = [ "post-{$course_id}-|" ]; $terms = wp_get_post_terms( $course_id, get_object_taxonomies( 'academy_courses' ) ); foreach ( is_array( $terms ) ? $terms : [] as $term ) { $specifics[] = "tax-{$term->term_id}--single-{$term->taxonomy}"; } return [ 'rules' => [ 'basic-global', 'basic-singulars', 'academy_courses|all' ], 'specifics' => $specifics, ]; } /** * Whether a group other than $group_id still grants the user this course. * * @param int $user_id * @param int $course_id * @param int $group_id The group being left. */ protected function has_other_group_access( int $user_id, int $course_id, int $group_id ): bool { if ( ! class_exists( \StoreEngine\Addons\Membership\Access::class ) ) { return false; } $linked = array_map( 'intval', (array) get_post_meta( $course_id, self::COURSE_MEMBERSHIP_META, false ) ); foreach ( \StoreEngine\Addons\Membership\Access::get_active_user_groups( $user_id ) as $other ) { $other = (int) $other; if ( $other === $group_id || ! \StoreEngine\Addons\Membership\Access::user_has_group_access( $user_id, $other ) ) { continue; } if ( in_array( $other, $linked, true ) || $this->group_protects_course( $other, $course_id ) ) { return true; } } return false; } /** * Academy course ids whose access is granted by a membership group. * * A course stores its linked group in the `academy_store_membership` meta * (see Ajax\Membership::save_membership), so the grant is a reverse lookup. * * @param int $group_id * * @return int[] */ protected function get_group_course_ids( int $group_id ): array { if ( ! $group_id ) { return []; } $courses = get_posts( [ 'post_type' => 'academy_courses', 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_key' => self::COURSE_MEMBERSHIP_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'meta_value' => $group_id, ] ); return is_array( $courses ) ? array_map( 'intval', $courses ) : []; } }