Source: includes/enrolment/class-sensei-course-enrolment.php

<?php
/**
 * File containing the class Sensei_Course_Enrolment.
 *
 * @package sensei
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Handles course enrolment logic for a particular course.
 *
 * Each course/user combination has its own results record (`Sensei_Course_Enrolment_Provider_Results`)
 * stored in user meta. There are several ways in which result records can be invalidated so that
 * enrolment providers are asked to recalculate.
 *
 * - The user meta record can be set to an empty string. This marks it as invalid and needing recalculation. One
 *   common way this will happen is by a provider calling `\Sensei_Course_Enrolment_Manager::trigger_course_enrolment_check`
 *   to trigger the recalculation.
 * - The version hash for the record could be changed. The version hash is made up of three components. If
 *   any single component changes, the record will be recalculated. The three components are:
 *   - Site wide hash: If this changes, every enrolment result record is invalidated.
 *   - Course hash: If this changes for a course, every enrolment result related to that specific course is invalidated.
 *   - Hash of provider versions: If an update occurs that includes changed logic for one of the enrolment providers,
 *     all enrolment results are invalidated.
 */
class Sensei_Course_Enrolment {
	const META_PREFIX_ENROLMENT_RESULTS = 'sensei_course_enrolment_';
	const META_COURSE_ENROLMENT_VERSION = '_course_enrolment_version';
	const META_REMOVED_LEARNERS         = 'sensei_removed_learners';

	/**
	 * Courses instances.
	 *
	 * @var static[]
	 */
	private static $instances = [];

	/**
	 * Enrolment providers handling this particular course.
	 *
	 * @var Sensei_Course_Enrolment_Provider_Interface[]
	 */
	private $course_enrolment_providers;

	/**
	 * Course ID for this enrolment object.
	 *
	 * @var int
	 */
	private $course_id;

	/**
	 * An array of removed learners from the course.
	 *
	 * @var array {
	 *     @type string $date  Timestamp of when the the learner was removed.
	 * }
	 */
	private $removed_learners;

	/**
	 * Sensei_Course_Enrolment constructor.
	 *
	 * @param int $course_id Course ID to handle checks for.
	 */
	private function __construct( $course_id ) {
		$this->course_id = $course_id;
	}

	/**
	 * Get instance for a particular course.
	 *
	 * @param int $course_id Course ID to handle checks for.
	 *
	 * @return self
	 */
	public static function get_course_instance( $course_id ) {
		if ( ! isset( self::$instances[ $course_id ] ) ) {
			self::$instances[ $course_id ] = new static( $course_id );
		}

		return self::$instances[ $course_id ];
	}

	/**
	 * Gets the course ID for this enrolment object.
	 *
	 * @return int
	 */
	public function get_course_id() {
		return $this->course_id;
	}

	/**
	 * Check if a user is enrolled in a course.
	 *
	 * @param int  $user_id     User ID.
	 * @param bool $check_cache Check and use cached result.
	 *
	 * @return bool
	 */
	public function is_enrolled( $user_id, $check_cache = true ) {

		/**
		 * Allow complete side-stepping of enrolment handling in Sensei.
		 *
		 * This will have some other side-effects. For example, if using learner queries (My Courses,
		 * Learner Profiles, etc), you will have to save the learner term and association by using the
		 * `\Sensei_Course_Enrolment::save_enrolment` method. Additionally, manual enrolment handling
		 * in Learner Management will not have any effect.
		 *
		 * @since 3.0.0
		 *
		 * @hook sensei_is_enrolled
		 *
		 * @param {bool|null} $is_enrolled If a boolean, that value will be used. Null values will keep default behavior.
		 * @param {int}       $user_id     User ID.
		 * @param {int}       $course_id   Course post ID.
		 * @param {bool}      $check_cache Advise hooked method if cached values should be trusted.
		 * @return {bool|null} Filtered value.
		 */
		$is_enrolled = apply_filters( 'sensei_is_enrolled', null, $user_id, $this->course_id, $check_cache );
		if ( null !== $is_enrolled ) {
			return $is_enrolled;
		}

		if ( ! $user_id ) {
			return false;
		}

		// User is not enrolled if the course is not published or he is removed.
		if ( 'publish' !== get_post_status( $this->course_id ) || $this->is_learner_removed( $user_id ) ) {
			return false;
		}

		try {
			if ( $check_cache ) {
				$enrolment_check_results = $this->get_enrolment_check_results( $user_id );
				if (
					$enrolment_check_results
					&& $enrolment_check_results->get_version_hash() === $this->get_current_enrolment_result_version()
				) {
					return $this->has_stored_enrolment( $user_id );
				}
			}

			$enrolment_check_results = $this->query_enrolment_providers( $user_id );
			$is_enrolled             = $enrolment_check_results->is_enrolment_provided();

			$this->save_enrolment( $user_id, $is_enrolled );
		} catch ( Exception $e ) {
			$is_enrolled = false;
		}

		return $is_enrolled;
	}

	/**
	 * Marks all enrolment results as invalid for a course and enqueues an async job to recalculate.
	 *
	 * This will still cause a delay when users visit My Courses or another page that relies on the term.
	 * We aren't invalidating the entire user for this.
	 *
	 * @return Sensei_Enrolment_Course_Calculation_Job|null
	 */
	public function recalculate_enrolment() {
		$this->reset_course_enrolment_salt();
		$job_scheduler = Sensei_Enrolment_Job_Scheduler::instance();

		return $job_scheduler->start_course_calculation_job( $this->course_id );
	}

	/**
	 * Invalidate a single learner/course enrolment result.
	 *
	 * Note: this could still cause a delay when users visit My Courses or another page that relies on the term.
	 * We aren't invalidating the entire user for this.
	 *
	 * @param int $user_id User ID.
	 */
	public function invalidate_learner_result( $user_id ) {
		update_user_meta( $user_id, $this->get_enrolment_results_meta_key(), '' );
	}

	/**
	 * Get the IDs for the enrolled users.
	 *
	 * @param array $args Additional arguments to pass to `WP_Term_Query`. Useful for pagination.
	 *
	 * @return int[]
	 */
	public function get_enrolled_user_ids( $args = [] ) {
		$args['fields'] = 'names';

		$learner_terms = wp_get_object_terms( $this->course_id, Sensei_PostTypes::LEARNER_TAXONOMY_NAME, $args );

		// This only happens if we asked for terms too early (before init).
		if ( is_wp_error( $learner_terms ) ) {
			return [];
		}

		return array_map( [ 'Sensei_learner', 'get_learner_id' ], $learner_terms );
	}

	/**
	 * Get enrolment from taxonomy record.
	 *
	 * @param int $user_id User ID.
	 * @return bool
	 * @throws Exception When learner term could not be created.
	 */
	private function has_stored_enrolment( $user_id ) {
		$term = Sensei_Learner::get_learner_term( $user_id );

		// We are retrieving the associated object_ids from the term and not the other way around (has_term) for performance reasons.
		$object_ids = get_objects_in_term( $term->term_id, Sensei_PostTypes::LEARNER_TAXONOMY_NAME );

		return in_array( (string) $this->course_id, $object_ids, true );
	}

	/**
	 * Save enrolment in taxonomy.
	 *
	 * @param int  $user_id     User ID.
	 * @param bool $is_enrolled If the user is enrolled in the course.
	 *
	 * @return bool
	 * @throws Exception When learner term could not be created.
	 */
	public function save_enrolment( $user_id, $is_enrolled ) {
		$term = Sensei_Learner::get_learner_term( $user_id );

		$is_enrolled_current = $this->has_stored_enrolment( $user_id );

		// Nothing has changed.
		if ( $is_enrolled_current === $is_enrolled ) {
			return true;
		}

		if ( ! $is_enrolled ) {
			$result = true === wp_remove_object_terms( $this->course_id, [ intval( $term->term_id ) ], Sensei_PostTypes::LEARNER_TAXONOMY_NAME );
		} else {
			// If they are enrolled, make sure they have started the course.
			Sensei_Utils::user_start_course( $user_id, $this->course_id );

			$save_result = wp_set_post_terms( $this->course_id, [ intval( $term->term_id ) ], Sensei_PostTypes::LEARNER_TAXONOMY_NAME, true );
			$result      = is_array( $save_result ) && ! empty( $save_result );
		}

		if ( ! $result ) {
			return false;
		}

		/**
		 * Fire action when course enrolment status changes.
		 *
		 * @since 3.0.0
		 *
		 * @hook sensei_course_enrolment_status_changed
		 *
		 * @param {int}  $user_id     User ID.
		 * @param {int}  $course_id   Course post ID.
		 * @param {bool} $is_enrolled New enrolment status.
		 */
		do_action( 'sensei_course_enrolment_status_changed', $user_id, $this->course_id, $is_enrolled );

		return true;
	}

	/**
	 * Get the enrolment check results for a user.
	 *
	 * @access private Used internally only.
	 *
	 * @param int $user_id User ID.
	 *
	 * @return bool|Sensei_Course_Enrolment_Provider_Results
	 * @throws Exception When learner term could not be created.
	 */
	public function get_enrolment_check_results( $user_id ) {
		$enrolment_check_results = get_user_meta( $user_id, $this->get_enrolment_results_meta_key(), true );

		if ( empty( $enrolment_check_results ) ) {
			return false;
		}

		return Sensei_Course_Enrolment_Provider_Results::from_json( $enrolment_check_results );
	}

	/**
	 * Builds a new enrolment results record by checking with enrolment providers.
	 *
	 * @param int $user_id User ID.
	 *
	 * @return Sensei_Course_Enrolment_Provider_Results
	 * @throws Exception When learner term could not be created.
	 */
	private function query_enrolment_providers( $user_id ) {
		$provider_results = [];
		foreach ( $this->get_course_enrolment_providers() as $enrolment_provider_id => $enrolment_provider ) {
			$provider_results[ $enrolment_provider_id ] = $enrolment_provider->is_enrolled( $user_id, $this->course_id );
		}

		$enrolment_results = new Sensei_Course_Enrolment_Provider_Results( $provider_results, $this->get_current_enrolment_result_version() );

		$this->store_enrolment_results( $user_id, $enrolment_results );

		Sensei_Enrolment_Provider_Journal_Store::register_possible_enrolment_change(
			$enrolment_results,
			$user_id,
			$this->course_id
		);

		/**
		 * Notify upon calculation of enrolment results.
		 *
		 * @since 3.0.0
		 *
		 * @hook sensei_enrolment_results_calculated
		 *
		 * @param {Sensei_Course_Enrolment_Provider_Results} $enrolment_results Enrolment results object.
		 * @param {int}                                      $course_id         Course post ID.
		 * @param {int}                                      $user_id           User ID.
		 */
		do_action( 'sensei_enrolment_results_calculated', $enrolment_results, $this->course_id, $user_id );

		return $enrolment_results;
	}

	/**
	 * Store the enrolment results in user meta.
	 *
	 * @param int                                      $user_id           User ID.
	 * @param Sensei_Course_Enrolment_Provider_Results $enrolment_results Enrolment results object.
	 */
	private function store_enrolment_results( $user_id, Sensei_Course_Enrolment_Provider_Results $enrolment_results ) {
		$results_meta_key   = $this->get_enrolment_results_meta_key();
		$had_existing_value = ! empty( get_user_meta( $user_id, $results_meta_key, true ) );

		/**
		 * Filter on whether course enrolment results should be stored.
		 *
		 * @since 3.0.0
		 *
		 * @hook sensei_course_enrolment_store_results
		 *
		 * @param {bool}                                     $store_results      Whether to store the results.
		 * @param {int}                                      $user_id            User ID.
		 * @param {int}                                      $course_id          Course post ID.
		 * @param {bool}                                     $had_existing_value True if a stale enrolment result is already stored.
		 * @param {Sensei_Course_Enrolment_Provider_Results} $enrolment_results  Enrolment results object.
		 * @return {bool} Filtered value.
		 */
		$store_results = apply_filters( 'sensei_course_enrolment_store_results', true, $user_id, $this->course_id, $had_existing_value, $enrolment_results );

		if ( $store_results ) {
			update_user_meta( $user_id, $results_meta_key, wp_slash( wp_json_encode( $enrolment_results ) ) );
		} elseif ( $had_existing_value ) {
			// This will only occur if the filter returns something other than the default.
			delete_user_meta( $user_id, $results_meta_key );
		}
	}

	/**
	 * Helper to disable storing enrolment results when it isn't already stored and enrolment isn't provided.
	 *
	 * @param bool                                     $store_results      Whether to store the results.
	 * @param int                                      $user_id            User ID.
	 * @param int                                      $course_id          Course post ID.
	 * @param bool                                     $had_existing_value True if a stale enrolment result is already stored.
	 * @param Sensei_Course_Enrolment_Provider_Results $enrolment_results  Enrolment results object.
	 *
	 * @return bool
	 */
	public static function do_not_store_negative_enrolment_results( $store_results, $user_id, $course_id, $had_existing_value, $enrolment_results ) {
		return $had_existing_value || $enrolment_results->is_enrolment_provided();
	}

	/**
	 * Get the enrolment results meta key.
	 *
	 * @return string
	 */
	public function get_enrolment_results_meta_key() {
		global $wpdb;

		return $wpdb->get_blog_prefix() . self::META_PREFIX_ENROLMENT_RESULTS . $this->course_id;
	}

	/**
	 * Get a enrolment provider's state for a user.
	 *
	 * @param Sensei_Course_Enrolment_Provider_Interface $provider Provider object.
	 * @param int                                        $user_id  User ID.
	 *
	 * @return Sensei_Enrolment_Provider_State
	 * @throws Exception When learner term could not be created.
	 */
	public function get_provider_state( Sensei_Course_Enrolment_Provider_Interface $provider, $user_id ) {
		return Sensei_Enrolment_Provider_State_Store::get( $user_id )->get_provider_state( $provider, $this->course_id );
	}

	/**
	 * Get an array of all the enrolment providers that are handling this course's enrolment.
	 *
	 * @return Sensei_Course_Enrolment_Provider_Interface[]
	 */
	private function get_course_enrolment_providers() {
		if ( ! isset( $this->course_enrolment_providers ) ) {
			$this->course_enrolment_providers = [];

			$enrolment_manager   = Sensei_Course_Enrolment_Manager::instance();
			$enrolment_providers = $enrolment_manager->get_all_enrolment_providers();

			foreach ( $enrolment_providers as $id => $enrolment_provider ) {
				if ( $enrolment_provider->handles_enrolment( $this->course_id ) ) {
					$this->course_enrolment_providers[ $id ] = $enrolment_provider;
				}
			}
		}

		return $this->course_enrolment_providers;
	}

	/**
	 * Get the version hash that current enrolment results should be at.
	 *
	 * @return string
	 */
	public function get_current_enrolment_result_version() {
		$enrolment_manager = Sensei_Course_Enrolment_Manager::instance();

		$hash_components   = [];
		$hash_components[] = $enrolment_manager->get_site_salt();
		$hash_components[] = $enrolment_manager->get_enrolment_provider_versions_hash();
		$hash_components[] = $this->get_course_enrolment_salt();

		return md5( implode( '-', $hash_components ) );
	}

	/**
	 * Gets the course salt that can be used to invalidate all course enrolments.
	 *
	 * @return string
	 */
	public function get_course_enrolment_salt() {
		$course_salt = get_post_meta( $this->course_id, self::META_COURSE_ENROLMENT_VERSION, true );

		if ( ! $course_salt ) {
			return $this->reset_course_enrolment_salt();
		}

		return $course_salt;
	}

	/**
	 * Resets the course salt. If already set, this will invalidate all enrolment results for the current course.
	 *
	 * @return string
	 */
	public function reset_course_enrolment_salt() {
		$new_salt = md5( uniqid() );

		update_post_meta( $this->course_id, self::META_COURSE_ENROLMENT_VERSION, $new_salt );

		return $new_salt;
	}

	/**
	 * Remove learner from the course, overriding the providers rule.
	 *
	 * @param int $user_id User ID.
	 *
	 * @return boolean Success flag.
	 */
	public function remove_learner( $user_id ) {
		$removed_learners = $this->get_removed_learners();

		if ( isset( $removed_learners[ $user_id ] ) ) {
			return false;
		}

		$removed_learners[ $user_id ] = [ 'date' => time() ];

		$this->save_enrolment( $user_id, false );

		return $this->update_removed_learners( $removed_learners );
	}

	/**
	 * Restore removed learner enrolment, giving the control back to the providers.
	 *
	 * @param int $user_id User ID.
	 *
	 * @return boolean Success flag.
	 */
	public function restore_learner( $user_id ) {
		$removed_learners = $this->get_removed_learners();

		unset( $removed_learners[ $user_id ] );

		return $this->update_removed_learners( $removed_learners );
	}

	/**
	 * Check if the user is removed.
	 *
	 * @param int $user_id User ID.
	 *
	 * @return boolean Whether the learner is removed.
	 */
	public function is_learner_removed( $user_id ) {
		$removed_learners = $this->get_removed_learners();

		return array_key_exists( $user_id, $removed_learners );
	}

	/**
	 * Get removed learners meta.
	 *
	 * @return array Removed learners array.
	 */
	private function get_removed_learners() {
		if ( isset( $this->removed_learners ) ) {
			return $this->removed_learners;
		}

		$removed_learners_json = get_post_meta( $this->course_id, self::META_REMOVED_LEARNERS, true );

		if ( empty( $removed_learners_json ) ) {
			$this->removed_learners = [];
		} else {
			$removed_learners = json_decode( $removed_learners_json, true );

			if ( ! $removed_learners ) {
				$this->removed_learners = [];
			} else {
				$this->removed_learners = $removed_learners;
			}
		}

		return $this->removed_learners;
	}

	/**
	 * Update removed learners meta.
	 *
	 * @param array $removed_learners Removed learners array.
	 *
	 * @return bool Whether it was updated.
	 */
	private function update_removed_learners( $removed_learners ) {
		$result = update_post_meta( $this->course_id, self::META_REMOVED_LEARNERS, wp_json_encode( $removed_learners ) );

		if ( $result ) {
			$this->removed_learners = $removed_learners;
			return true;
		}

		return false;
	}

	/**
	 * Withdraw learner. It removes the manual enrolment and/or remove the learner,
	 * depending on the user enrollment situation.
	 *
	 * @param int $user_id User ID.
	 *
	 * @return boolean If user is withdrawn.
	 */
	public function withdraw( $user_id ) {
		$enrolment_manager         = Sensei_Course_Enrolment_Manager::instance();
		$manual_enrolment_provider = $enrolment_manager->get_manual_enrolment_provider();

		if ( $manual_enrolment_provider instanceof Sensei_Course_Manual_Enrolment_Provider ) {
			$manual_enrolment_provider->withdraw_learner( $user_id, $this->course_id );
		}

		if ( ! $this->is_enrolled( $user_id, false ) ) {
			return true;
		}

		// If user is still enrolled for some reason, remove them.
		$this->remove_learner( $user_id );

		return ! $this->is_enrolled( $user_id, false );
	}

	/**
	 * Enroll learner. It restore a learner, if they are enrolled through a provider,
	 * otherwise, give them a manually enrollment.
	 *
	 * @param int $user_id User ID.
	 *
	 * @return boolean If user is enrolled.
	 */
	public function enrol( $user_id ) {

		/**
		 * Notify when a user should be enrolled to the course.
		 *
		 * @since 3.13.3
		 *
		 * @hook sensei_admin_enrol_user
		 *
		 * @param {int} $course_id Course that the user will be enrolled to.
		 * @param {int} $user_id   User ID.
		 */
		do_action( 'sensei_admin_enrol_user', $this->course_id, $user_id );

		// If user is removed, just restore.
		if ( $this->is_learner_removed( $user_id ) ) {
			$this->restore_learner( $user_id );
		}

		if ( $this->is_enrolled( $user_id, false ) ) {
			return true;
		}

		// If user isn't still enrolled, enroll manually.
		$enrolment_manager         = Sensei_Course_Enrolment_Manager::instance();
		$manual_enrolment_provider = $enrolment_manager->get_manual_enrolment_provider();

		if ( ! ( $manual_enrolment_provider instanceof Sensei_Course_Manual_Enrolment_Provider ) ) {
			return false;
		}

		return $manual_enrolment_provider->enrol_learner( $user_id, $this->course_id );
	}
}