$isEnrolled ? self::getCompletedLessonIds($courseId, $userId) : [], 'isEnrolled' => $isEnrolled, 'progress' => $isEnrolled ? self::getCourseProgress($courseId, $userId) : 0, ]; } public static function resolveLessonAccess($initialCanView, $lesson, $course, $user, $ctx = []) { $canView = apply_filters('fluent_community/course/can_view_lesson', $initialCanView, $lesson, $course, $user); $access = apply_filters('fluent_community/course/lesson_access_info', [ 'can_view' => $canView, 'lock_type' => '', ], $lesson, $course, $user, $ctx); if (!empty($access['can_view'])) { $access['lock_type'] = ''; } return $access; } public static function isEnrolled($courseId, $userId = null) { return (bool) self::getCourseEnrollment($courseId, $userId); } public static function getCourseEnrollment($courseId, $userId = null) { if (!$userId) { $userId = get_current_user_id(); } if (!$userId) { return null; } static $enrollmentCache = []; $key = $courseId . '_' . $userId; if (array_key_exists($key, $enrollmentCache)) { return $enrollmentCache[$key]; } return $enrollmentCache[$key] = SpaceUserPivot::where('space_id', $courseId) ->where('user_id', $userId) ->first(); } public static function getCoursePublishedLessonIds($courseId) { static $counts = []; if (isset($counts[$courseId])) { return $counts[$courseId]; } $counts[$courseId] = CourseLesson::where('space_id', $courseId) ->where('status', 'published') ->pluck('id') ->toArray(); return $counts[$courseId]; } public static function getCourseProgress($courseId, $userId = null) { if (!$userId) { $userId = get_current_user_id(); } if (!$userId) { return 0; } $progressMap = self::getBulkCourseProgress($courseId, [$userId]); return $progressMap[$userId] ?? 0; } public static function getBulkCourseProgress($courseId, $userIds = []) { if (empty($userIds)) { return []; } $progressMap = array_fill_keys($userIds, 0); $lessonIds = self::getCoursePublishedLessonIds($courseId); $totalLessons = count($lessonIds); if (!$totalLessons) { return $progressMap; } $completionCounts = Reaction::whereIn('user_id', $userIds) ->whereIn('object_id', $lessonIds) ->where('object_type', 'lesson_completed') ->where('type', 'completed') ->groupBy('user_id') ->selectRaw('user_id, COUNT(*) as completed_count') ->pluck('completed_count', 'user_id'); foreach ($completionCounts as $userId => $completed) { $result = floor(($completed / $totalLessons) * 100); $progressMap[$userId] = min($result, 100); } return $progressMap; } public static function getCompletedLessonIds($courseId, $userId = null) { if (!$userId) { $userId = get_current_user_id(); } if (!$userId) { return []; } $key = $courseId . '_' . $userId; if (isset(self::$completedLessonsCache[$key])) { return self::$completedLessonsCache[$key]; } return self::$completedLessonsCache[$key] = Reaction::where('user_id', $userId) ->where('parent_id', $courseId) ->where('object_type', 'lesson_completed') ->where('type', 'completed') ->pluck('object_id') ->toArray(); } public static function overallCourseProgressAverage($course) { $lessonsCount = CourseLesson::where('space_id', $course->id) ->where('status', 'published') ->count(); if (!$lessonsCount) { return 0; } $allCompletedCount = Reaction::where('object_type', 'lesson_completed') ->where('type', 'completed') ->where('parent_id', $course->id) ->count(); $studentsCount = $course->students_count ? $course->students_count : $course->students()->count(); if (!$studentsCount) { return 0; } // calculate the average of all students return ceil(($allCompletedCount / ($studentsCount * $lessonsCount)) * 100); } public static function updateLessonCompletion($lesson, $userId, $state = 'completed') { $reaction = Reaction::where('user_id', $userId) ->where('object_id', $lesson->id) ->where('object_type', 'lesson_completed') ->first(); if ($reaction) { if ($state != 'completed') { $reaction->type = 'incomplete'; $reaction->save(); do_action('fluent_community/course/lesson_marked_incomplete', $lesson, $userId); } else { $reaction->type = 'completed'; $reaction->save(); } return true; } Reaction::create([ 'user_id' => $userId, 'object_id' => $lesson->id, 'object_type' => 'lesson_completed', 'parent_id' => $lesson->space_id, 'type' => 'completed' ]); do_action('fluent_community/course/lesson_completed', $lesson, $userId); // check if the whole module is completed $allTopicLessonIds = CourseLesson::where('space_id', $lesson->space_id) ->where('parent_id', $lesson->parent_id) ->where('status', 'published') ->pluck('id') ->toArray(); $completedLessonCount = Reaction::where('user_id', $userId) ->whereIn('object_id', $allTopicLessonIds) ->where('object_type', 'lesson_completed') ->where('type', 'completed') ->count(); if (count($allTopicLessonIds) == $completedLessonCount) { $topic = CourseTopic::find($lesson->parent_id); do_action('fluent_community/course/topic_completed', $topic, $userId, $lesson); } return true; } public static function getCourseMeta($key, $default = false, $withModel = false) { $meta = Meta::where('meta_key', $key) ->where('object_type', 'course') ->first(); if ($withModel) { return $meta; } return $meta ? $meta->value : $default; } public static function updateCourseMeta($key, $value) { $meta = Meta::where('meta_key', $key) ->where('object_type', 'course') ->first(); if ($meta) { $meta->update([ 'value' => $value ]); return $meta; } return Meta::create([ 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'value' => $value, 'object_type' => 'course' ]); } public static function completeCourse($course, $lesson, $userId) { $activity = Activity::where('feed_id', $course->id) ->where('user_id', $userId) ->where('action_name', 'course_completed') ->first(); if ($activity) { if (strtotime($lesson->scheduled_at) > strtotime($activity->updated_at)) { $activity->updated_at = current_time('mysql'); $activity->save(); do_action('fluent_community/course/completed', $course, $userId); return true; } return false; } Activity::create([ 'feed_id' => $course->id, 'user_id' => $userId, 'action_name' => 'course_completed' ]); do_action('fluent_community/course/completed', $course, $userId); return true; } public static function resetCourseProgress($courseId, $userId) { $course = Course::find($courseId); if (!$course || !$userId) { return false; } do_action('fluent_community/course/before_progress_reset', $course, $userId); try { Helper::dbTransaction(function () use ($userId, $courseId) { // Wipe completion state entirely (both 'completed' and 'incomplete' types) so the student starts pristine. Reaction::where('user_id', $userId) ->where('parent_id', $courseId) ->where('object_type', 'lesson_completed') ->delete(); Activity::where('user_id', $userId) ->where('feed_id', $courseId) ->where('action_name', 'course_completed') ->delete(); }); } catch (\Exception $e) { return false; } unset(self::$completedLessonsCache[$courseId . '_' . $userId]); do_action('fluent_community/course/progress_reset', $course, $userId); return true; } public static function enrollCourse($course, $userId = null, $by = 'self', $skipSync = false) { return Helper::addToSpace($course, $userId, 'student', $by, $skipSync); } public static function enrollCourses($courseIds, $userId = null, $by = 'self') { if (!$userId) { $userId = get_current_user_id(); } $enrolledCourseIds = []; foreach ($courseIds as $courseId) { $course = Course::find($courseId); if ($course) { if (self::enrollCourse($course, $userId, $by)) { $enrolledCourseIds[] = $courseId; } } } return $enrolledCourseIds; } public static function leaveCourse($course, $userId = null, $by = 'self') { if (!$userId) { $userId = get_current_user_id(); } if (!$userId) { return false; } return Helper::removeFromSpace($course, $userId, $by); } public static function leaveCourses($courseIds, $userId = null, $by = 'self') { if (!$userId) { $userId = get_current_user_id(); } $processedCourseIds = []; foreach ($courseIds as $courseId) { $course = Course::find($courseId); if ($course) { if (self::leaveCourse($course, $userId, $by)) { $processedCourseIds[] = $courseId; } } } return $processedCourseIds; } public static function getEnrolledCourseIds($userId = null) { if (!$userId) { $userId = get_current_user_id(); } if (!$userId) { return []; } return SpaceUserPivot::where('user_id', $userId) ->where('role', 'student') ->pluck('space_id') ->toArray(); } public static function getSectionAccessDate($section, $courseType, $enrollment = null) { if ($courseType == 'self_paced') { return null; } if ($courseType == 'scheduled') { return $section->scheduled_at; } // This is for structured course if (!$enrollment) { return null; } $accessAfterEnrollment = $section->reactions_count; return gmdate('Y-m-d H:i:s', strtotime($enrollment->created_at) + $accessAfterEnrollment * 86400); } public static function sanitizeLessonMeta($meta, $lesson) { $validFields = [ 'enable_comments', 'enable_media', 'media', 'video_length', 'featured_image_id', 'free_preview_lesson', 'require_video_completion', 'video_completion_threshold', 'auto_complete_on_video_end' ]; if ($lesson->isQuizType()) { $quizFields = ['quiz_questions', 'passing_score', 'enable_passing_score', 'enforce_passing_score', 'hide_result']; $validFields = wp_parse_args($validFields, $quizFields); } $meta = Arr::only($meta, $validFields); $yesNoFields = ['enable_comments', 'enable_media', 'free_preview_lesson', 'require_video_completion', 'auto_complete_on_video_end', 'enable_passing_score', 'enforce_passing_score', 'hide_result']; foreach ($yesNoFields as $field) { $meta[$field] = Arr::get($meta, $field, 'no') == 'yes' ? 'yes' : 'no'; } if (Arr::get($meta, 'enable_media') == 'yes') { if ($media = Arr::get($meta, 'media', [])) { $meta['media'] = self::sanitizeMedia($media); } } else { $meta['media'] = [ 'provider' => '' ]; } $numericFields = ['video_length', 'passing_score', 'video_completion_threshold']; foreach ($numericFields as $field) { $meta[$field] = absint(Arr::get($meta, $field, 0)); } // 0 means "not set" — the gate falls back to its default threshold if ($meta['video_completion_threshold']) { $meta['video_completion_threshold'] = min(100, max(1, $meta['video_completion_threshold'])); } return apply_filters('fluent_community/lesson/sanitize_meta', $meta, $lesson); } public static function sanitizeMedia($media) { return array_filter([ 'type' => sanitize_text_field(Arr::get($media, 'type', '')), 'url' => sanitize_url(Arr::get($media, 'url', '')), 'content_type' => sanitize_text_field(Arr::get($media, 'content_type', '')), 'provider' => sanitize_url(Arr::get($media, 'provider', '')), 'title' => sanitize_text_field(Arr::get($media, 'title', '')), 'author_name' => sanitize_text_field(Arr::get($media, 'author_name', '')), 'html' => CustomSanitizer::sanitizeRichText(Arr::get($media, 'html', '')), 'image' => sanitize_url(Arr::get($media, 'image', '')) ]); } public static function getCourseCategories() { $terms = Term::whereHas('base_spaces', function ($q) { $q->where('type', 'course'); })->get(); $formattedTerms = []; foreach ($terms as $term) { $formattedTerms[] = [ 'id' => $term->id, 'title' => $term->title, 'slug' => $term->slug ]; } return $formattedTerms; } public static function getUserCourses($userId = null) { if (!$userId) { $userId = get_current_user_id(); } if (!$userId) { return null; } return Course::whereHas('students', function ($q) use ($userId) { $q->where('user_id', $userId); }) ->with('enrollment', function ($q) use ($userId) { $q->where('user_id', $userId); }) ->get(); } public static function santizeLessonBody($body) { if (current_user_can('unfiltered_html')) { return $body; } return wp_kses_post($body); } public static function formatLessonData($course, $lesson, $user = null, $config = []) { $canViewLesson = Arr::get($config, 'can_view', false); $parseContent = Arr::get($config, 'parse_content', true); $inlineCss = ''; if ($parseContent && $canViewLesson) { $content = $lesson->message_rendered; if (!$content && $lesson->message) { $content = apply_filters('the_content', $lesson->message); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound if (function_exists('wp_style_engine_get_stylesheet_from_context')) { $core_styles_keys = array('block-supports'); // Adds comment if code is prettified to identify core styles sections in debugging. foreach ($core_styles_keys as $style_key) { $inlineCss .= wp_style_engine_get_stylesheet_from_context($style_key, []); } } } if (!$content) { $content = ''; } $content = (new SmartCodeParser())->parse($content, $user); } else { $content = ''; } $formattedLesson = [ 'id' => $lesson->id, 'title' => $lesson->title, 'content' => $content, 'slug' => $lesson->slug, 'course_id' => $lesson->space_id, 'section_id' => $lesson->parent_id, 'created_at' => $lesson->created_at->format('Y-m-d H:i:s'), 'content_type' => $lesson->content_type, 'featured_image' => $lesson->featured_image, 'meta' => $lesson->getPublicLessonMeta($canViewLesson), 'comments_count' => $lesson->comments_count, 'is_locked' => Arr::get($config, 'is_locked', false), 'unlock_date' => Arr::get($config, 'unlock_date', ''), 'lock_type' => Arr::get($config, 'lock_type', ''), 'can_view' => $canViewLesson, 'inline_css' => $inlineCss ]; if (!$canViewLesson) { $formattedLesson['access_message'] = static::getAccessMessage($course, $lesson, $config); } return $formattedLesson; } public static function copyLessonDocuments($lesson, $newLesson) { if (!$lesson || !$newLesson) { return; } $newLessonMeta = $newLesson->meta; $newLessonMeta['document_lists'] = []; foreach ($lesson->media as $media) { if (!$media->is_active) { continue; } $newMedia = $media->replicate(); $newMedia->feed_id = $newLesson->id; $newMedia->save(); if ($media->object_source == 'lesson_document') { $newLessonMeta['document_lists'][] = $newMedia->getPrivateFileMeta(); } } $newLesson->meta = $newLessonMeta; $newLesson->save(); } public static function getAccessMessage($course, $lesson, $config) { $isLocked = Arr::get($config, 'is_locked', false); $unlockDate = Arr::get($config, 'unlock_date', ''); $courseLessonsUrl = Helper::baseUrl('/course/' . $course->slug . '/lessons'); $headerMessage = __('This lesson is currently locked', 'fluent-community'); $bodyMessage = __('Please enroll in this course to access this lesson', 'fluent-community'); $backToCourseText = __('Back to Course', 'fluent-community'); if ($isLocked && Arr::get($config, 'lock_type') === 'sequential') { $headerMessage = __('Complete the previous lesson first', 'fluent-community'); $bodyMessage = __('You need to complete the previous lesson before unlocking this one.', 'fluent-community'); } elseif ($isLocked && $unlockDate) { $headerMessage = __('This lesson is not published for you yet', 'fluent-community'); /* translators: %s is replaced by the date */ $bodyMessage = sprintf(__('It will be available to you on %s', 'fluent-community'), date_i18n(get_option('date_format'), strtotime($unlockDate)) ); } /* translators: %s is replaced by the header message, %s is replaced by the body message, %s is replaced by the course lessons url, %s is replaced by the back to course text */ $accessMessage = sprintf('
', $headerMessage, $bodyMessage, $courseLessonsUrl, $backToCourseText ); return apply_filters('fluent_community/course/access_message_html', $accessMessage, $course, $lesson, $config); } public static function getParsedEmailSubject($text, $section, $user) { $parsedSubject = (new SmartCodeParser())->parse($text, $user, $section, false); return $parsedSubject; } public static function getParsedEmailBody($text, $section, $user) { $parsedBody = (new SmartCodeParser())->parse($text, $user, $section); $emailComposer = new \FluentCommunity\App\Services\Libs\EmailComposer(); $emailComposer->addBlock('html_content', $parsedBody); $emailComposer->setDefaultLogo(); $emailComposer->setDefaultFooter(); return $emailComposer->getHtml(); } }