prefix and fixed strings, and every // value goes through $wpdb->prepare() or $wpdb->insert/update/delete. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching class Gamification { const POINTS_LESSON_COMPLETE = 10; const POINTS_QUIZ_PASS = 25; const POINTS_COURSE_COMPLETE = 100; public static function init() { add_action( 'accessnow_slms_lesson_completed', array( __CLASS__, 'on_lesson_completed' ), 10, 4 ); add_action( 'accessnow_slms_quiz_submitted', array( __CLASS__, 'on_quiz_submitted' ), 10, 5 ); add_action( 'accessnow_slms_quiz_regraded', array( __CLASS__, 'on_quiz_regraded' ), 10, 3 ); add_action( 'accessnow_slms_course_completed', array( __CLASS__, 'on_course_completed' ), 10, 3 ); } /** * Points for a completed lesson. * * @param int $user_id User ID. * @param int $lesson_id Lesson ID. * @param int $course_id Course ID. * @param int $enrollment_id Enrolment ID. */ public static function on_lesson_completed( $user_id, $lesson_id, $course_id, $enrollment_id ) { self::award_points( $user_id, Settings::get( 'points_lesson_complete', self::POINTS_LESSON_COMPLETE ), 'lesson_complete', $lesson_id, $enrollment_id ); } /** * Points for a passed quiz. * * @param int $user_id User ID. * @param int $quiz_id Quiz ID. * @param array $result Graded result. * @param int $attempt_number Attempt number. * @param int $enrollment_id Enrolment ID. */ public static function on_quiz_submitted( $user_id, $quiz_id, $result, $attempt_number, $enrollment_id ) { if ( ! empty( $result['passed'] ) ) { self::award_points( $user_id, Settings::get( 'points_quiz_pass', self::POINTS_QUIZ_PASS ), 'quiz_pass', $quiz_id, $enrollment_id ); } } /** * A quiz attempt marked or regraded as a pass after it was submitted earns the pass points then. * award_points() keeps one award per quiz and enrolment, so a regrade never pays twice. * * @param int $attempt_id Attempt ID. * @param object $old Attempt row before the change. * @param array $result New result (passed, pending, percentage). */ public static function on_quiz_regraded( $attempt_id, $old, $result ) { if ( ! empty( $result['passed'] ) && empty( $old->passed ) ) { self::award_points( (int) $old->user_id, Settings::get( 'points_quiz_pass', self::POINTS_QUIZ_PASS ), 'quiz_pass', (int) $old->quiz_id, (int) $old->enrollment_id ); } } /** * Points for a completed course. * * @param int $user_id User ID. * @param int $course_id Course ID. * @param int $enrollment_id Enrolment ID. */ public static function on_course_completed( $user_id, $course_id, $enrollment_id ) { self::award_points( $user_id, Settings::get( 'points_course_complete', self::POINTS_COURSE_COMPLETE ), 'course_complete', $course_id, $enrollment_id ); } public static function award_points( $user_id, $points, $action, $reference_id = 0, $enrollment_id = 0 ) { global $wpdb; /** * Points awarded for an action. Return 0 to award nothing. * * Each action is awarded once per enrolment and reference (lesson, quiz or course). * * @since 1.0.0 * * @param int $points Points from the settings. * @param string $action 'lesson_complete', 'quiz_pass' or 'course_complete'. * @param int $user_id User ID. * @param int $reference_id Lesson, quiz or course ID. * @param int $enrollment_id Enrolment ID. */ $points = (int) apply_filters( 'accessnow_slms_points', (int) $points, $action, (int) $user_id, (int) $reference_id, (int) $enrollment_id ); if ( 0 === $points ) { return false; } // Atomic duplicate prevention via UNIQUE KEY idx_points_dedup $result = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->prefix}slms_points (user_id, points, action, reference_id, enrollment_id, earned_at) VALUES (%d, %d, %s, %d, %d, %s)", $user_id, $points, sanitize_text_field( $action ), $reference_id, $enrollment_id, current_time( 'mysql', true ) ) ); if ( ! $result ) { return false; // Duplicate — already awarded } self::check_badges( $user_id ); return true; } public static function get_total_points( $user_id ) { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(points), 0) FROM {$wpdb->prefix}slms_points WHERE user_id = %d", $user_id ) ); } public static function get_points_history( $user_id, $limit = 20 ) { global $wpdb; return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}slms_points WHERE user_id = %d ORDER BY earned_at DESC LIMIT %d", $user_id, $limit ) ); } public static function award_badge( $user_id, $badge_type, $badge_label ) { global $wpdb; $table = $wpdb->prefix . 'slms_badges'; $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}slms_badges WHERE user_id = %d AND badge_type = %s", $user_id, $badge_type ) ); if ( $existing ) { return false; } $wpdb->insert( $table, array( 'user_id' => $user_id, 'badge_type' => sanitize_text_field( $badge_type ), 'badge_label' => sanitize_text_field( $badge_label ), 'earned_at' => current_time( 'mysql', true ), ), array( '%d', '%s', '%s', '%s' ) ); return true; } public static function get_user_badges( $user_id ) { global $wpdb; return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}slms_badges WHERE user_id = %d ORDER BY earned_at DESC", $user_id ) ); } public static function check_badges( $user_id ) { global $wpdb; // First Course Completed $completed_courses = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}slms_enrollments WHERE user_id = %d AND status = 'completed'", $user_id ) ); if ( $completed_courses >= 1 ) { self::award_badge( $user_id, 'first_course', __( 'First Course Completed', 'accessnow-lms' ) ); } if ( $completed_courses >= 5 ) { self::award_badge( $user_id, 'five_courses', __( '5 Courses Completed', 'accessnow-lms' ) ); } // Perfect Quiz Score $perfect_scores = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}slms_quiz_attempts WHERE user_id = %d AND score = 100.00", $user_id ) ); if ( $perfect_scores >= 1 ) { self::award_badge( $user_id, 'perfect_quiz', __( 'Perfect Quiz Score', 'accessnow-lms' ) ); } } }