PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.11.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.11.0
2.11.0 2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 All 78 releases
fluent-community / Modules / Course / Http / Controllers / CourseAdminController.php

CourseAdminController.php in FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses 2.11.0, at Modules/Course/Http/Controllers/CourseAdminController.php

1,355 lines 47.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCommunity\Modules\Course\Http\Controllers;
4
5 use FluentCommunity\App\App;
6 use FluentCommunity\App\Functions\Utility;
7 use FluentCommunity\App\Http\Controllers\Controller;
8 use FluentCommunity\App\Models\BaseSpace;
9 use FluentCommunity\App\Models\Comment;
10 use FluentCommunity\App\Models\Reaction;
11 use FluentCommunity\App\Models\User;
12 use FluentCommunity\App\Models\XProfile;
13 use FluentCommunity\App\Services\CustomSanitizer;
14 use FluentCommunity\App\Services\Helper;
15 use FluentCommunity\App\Services\LockscreenService;
16 use FluentCommunity\App\Services\ProfileHelper;
17 use FluentCommunity\App\Services\FeedsHelper;
18 use FluentCommunity\Framework\Http\Request\Request;
19 use FluentCommunity\Framework\Support\Arr;
20 use FluentCommunity\App\Models\SpaceUserPivot;
21 use FluentCommunity\Modules\Course\Model\Course;
22 use FluentCommunity\Modules\Course\Model\CourseLesson;
23 use FluentCommunity\Modules\Course\Model\CourseTopic;
24 use FluentCommunity\Modules\Course\Services\CourseHelper;
25
26 class CourseAdminController extends Controller
27 {
28 public function getCourses(Request $request)
29 {
30 $user = $this->getUser();
31
32 $status = $request->getSafe('status');
33 $sortBy = $request->getSafe('sort_by', 'sanitize_text_field', 'latest');
34 $topicSlug = $request->getSafe('topic_slug');
35
36 $query = Course::searchBy($request->getSafe('search'))
37 ->byAdminAccess($user->ID)
38 ->byPostTopic($topicSlug);
39
40 if ($status && in_array($status, [ 'published', 'draft' ])) {
41 $query->where('status', $status);
42 }
43
44 if ($sortBy === 'alphabetical') {
45 $query->orderBy('title', 'ASC');
46 } else {
47 $query->orderBy('created_at', 'DESC');
48 }
49
50 $courses = $query->with([ 'owner' ])->paginate();
51
52 foreach ($courses as $course) {
53 $course->students_count = $course->students()->count();
54 if (!$course->cover_photo) {
55 $course->cover_photo = FLUENT_COMMUNITY_PLUGIN_URL . 'assets/images/course-placeholder.jpg';
56 }
57 $course->sectionsCount = CourseTopic::where('space_id', $course->id)->count();
58 $course->lessonsCount = CourseLesson::where('space_id', $course->id)->count();
59 }
60
61 $data = [
62 'courses' => $courses,
63 'course_categories' => $request->get('with_categories') ? CourseHelper::getCourseCategories() : [],
64 ];
65
66 return apply_filters('fluent_community/admin_courses_api_response', $data, $request->all());
67 }
68
69 public function createCourse(Request $request)
70 {
71 $this->validate($request->all(), [
72 'title' => 'required',
73 'description' => 'required',
74 'privacy' => 'required|in:public,private,secret',
75 'course_type' => 'required|in:self_paced,structured,scheduled',
76 ]);
77
78 $parentId = $request->get('parent_id');
79 if ($parentId) {
80 $serial = BaseSpace::where('parent_id', $parentId)->max('serial') + 1;
81 } else {
82 $serial = BaseSpace::max('serial') + 1;
83 }
84
85 $courseData = [
86 'parent_id' => $request->get('parent_id') ?: null, // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
87 'title' => $request->getSafe('title', 'sanitize_text_field'),
88 'privacy' => $request->get('privacy'),
89 'description' => wp_kses_post((string) $request->get('description', '')),
90 'status' => $request->get('status', 'draft'),
91 'settings' => [
92 'course_type' => $request->get('course_type'),
93 'emoji' => CustomSanitizer::sanitizeEmoji($request->get('settings.emoji', '')),
94 'shape_svg' => CustomSanitizer::sanitizeSvg($request->get('settings.shape_svg', '')),
95 'disable_comments' => $request->get('settings.disable_comments') === 'yes' ? 'yes' : 'no',
96 'hide_members_count' => $request->get('settings.hide_members_count') === 'yes' ? 'yes' : 'no',
97 'course_layout' => $request->get('settings.course_layout') === 'modern' ? 'modern' : 'classic',
98 'course_details' => CustomSanitizer::unslashMarkdown(trim((string) $request->get('settings.course_details', ''))),
99 'hide_instructor_view' => $request->get('settings.hide_instructor_view') === 'yes' ? 'yes' : 'no',
100 'show_instructor_students_count' => $request->get('settings.show_instructor_students_count') === 'yes' ? 'yes' : 'no',
101 'sequential_lesson_order' => $request->get('settings.sequential_lesson_order') === 'yes' ? 'yes' : 'no',
102 ],
103 'serial' => $serial,
104 ];
105
106 $lockScreenType = $request->get('settings.custom_lock_screen');
107 if (!in_array($lockScreenType, [ 'yes', 'no', 'redirect' ]) || $request->get('privacy') != 'private') {
108 $lockScreenType = 'no';
109 }
110
111 $courseData['settings']['custom_lock_screen'] = $lockScreenType;
112
113 if ($lockScreenType === 'redirect') {
114 $redirectUrl = $request->get('settings.onboard_redirect_url');
115 if (!$redirectUrl || !filter_var($redirectUrl, FILTER_VALIDATE_URL)) {
116 return $this->sendError([
117 'message' => __('Course Redirect URL is not valid', 'fluent-community'),
118 ]);
119 }
120 $courseData['settings']['onboard_redirect_url'] = sanitize_url($redirectUrl);
121 }
122
123 if ($request->get('privacy') == 'public' && $request->get('course_type') == 'self_paced') {
124 $courseData['settings']['public_lesson_view'] = $request->get('settings.public_lesson_view') == 'yes' ? 'yes' : 'no';
125 }
126
127 $slug = $request->get('slug');
128
129 $slug = $slug ? $slug : $courseData['title'];
130
131 $slug = preg_replace('/[^a-zA-Z0-9-_]/', '', $slug);
132
133 $slug = sanitize_title($slug, '');
134
135 if ($slug) {
136 $slug = Utility::slugify($slug);
137 $exist = Course::where('slug', $slug)
138 ->exists();
139
140 if ($exist) {
141 $slug = $slug . '-' . time();
142 }
143
144 $courseData['slug'] = $slug;
145 }
146
147 do_action('fluent_community/course/before_create', $courseData);
148
149 $course = Course::create($courseData);
150
151 $imageTypes = [ 'cover_photo', 'logo' ];
152
153 $metaData = [];
154 foreach ($imageTypes as $type) {
155 if (!empty($request->get($type))) {
156 $media = Helper::getMediaFromUrl($request->get($type));
157 if (!$media || $media->is_active) {
158 continue;
159 }
160 $metaData[$type] = $media->public_url;
161 $media->update([
162 'is_active' => true,
163 'user_id' => get_current_user_id(),
164 'sub_object_id' => $course->id,
165 'object_source' => 'space_' . $type,
166 ]);
167 }
168 }
169
170 if ($metaData) {
171 $course->fill($metaData);
172 $course->save();
173 }
174
175 if ($request->get('category_ids', [])) {
176 $topicsConfig = Helper::getTopicsConfig();
177 $categoryIds = (array) $request->get('category_ids', []);
178 $categoryIds = array_slice($categoryIds, 0, $topicsConfig['max_topics_per_space']);
179 $course->syncCategories($categoryIds);
180 }
181
182 do_action('fluent_community/course/created', $course);
183
184 return [
185 'message' => __('Course has been created successfully', 'fluent-community'),
186 'course' => $course,
187 ];
188 }
189
190 public function findCourse(Request $request, $courseId)
191 {
192 /** @var Course $course */
193 $course = Course::where('id', $courseId)
194 ->with([ 'owner' ])
195 ->firstOrFail();
196
197 $course->students_count = $course->students()->count();
198 $course->course_type = $course->settings['course_type'];
199 $course->lockscreen = $course->getLockscreen();
200
201 $course->category_ids = $course->categories->pluck('id')->toArray();
202
203 if ($course->students_count) {
204 $course->completed_students = $course->getCompletedStrundesCount();
205 $course->overAllProgress = CourseHelper::overallCourseProgressAverage($course);
206 }
207
208 unset($course->categories);
209
210 $course = apply_filters('fluent_community/course_info', $course, $request->all());
211
212 return [
213 'course' => $course,
214 ];
215 }
216
217 public function updateCourse(Request $request, $courseId)
218 {
219 $this->validate($request->all(), [
220 'title' => 'required',
221 'description' => 'required',
222 'privacy' => 'required|in:public,private,secret',
223 'status' => 'required|in:draft,published,archived',
224 'course_type' => 'required|in:self_paced,structured,scheduled',
225 'created_by' => 'exists:users,ID',
226 ]);
227
228 $course = Course::findOrFail($courseId);
229
230 $courseData = [
231 'title' => $request->getSafe('title', 'sanitize_text_field'),
232 'privacy' => $request->get('privacy'),
233 'description' => wp_kses_post((string) $request->get('description', '')),
234 'status' => $request->get('status'),
235 'cover_photo' => $request->getSafe('cover_photo', 'sanitize_url'),
236 'parent_id' => $request->get('parent_id') ?: null, // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
237 ];
238
239 $slug = $request->get('slug');
240 if ($slug && $course->slug != $slug) {
241 $slug = Utility::slugify($slug);
242
243 $exist = App::getInstance('db')->table('fcom_spaces')->where('slug', $slug)
244 ->where('id', '!=', $course->id)
245 ->exists();
246
247 if ($exist || !$slug) {
248 return $this->sendError([
249 'message' => __('Slug is already taken. Please use a different slug', 'fluent-community'),
250 ]);
251 }
252
253 $courseData['slug'] = $slug;
254 }
255
256 if ($request->get('created_by') && Helper::isSiteAdmin()) {
257 $courseData['created_by'] = (int)$request->get('created_by');
258 }
259
260 $imageTypes = [ 'cover_photo', 'logo' ];
261
262 foreach ($imageTypes as $type) {
263 if (!empty($request->get($type))) {
264 $media = Helper::getMediaFromUrl($request->get($type));
265 if (!$media || $media->is_active) {
266 continue;
267 }
268 $courseData[$type] = $media->public_url;
269 $media->update([
270 'is_active' => true,
271 'user_id' => get_current_user_id(),
272 'sub_object_id' => $course->id,
273 'object_source' => 'space_' . $type,
274 ]);
275 } else {
276 $courseData[$type] = null;
277 }
278 }
279
280 $existingSettings = $course->settings;
281 $existingSettings['course_type'] = $request->get('course_type');
282
283 $lockScreenType = $request->get('settings.custom_lock_screen');
284 if (!in_array($lockScreenType, [ 'yes', 'no', 'redirect' ]) || $request->get('privacy') != 'private') {
285 $lockScreenType = 'no';
286 }
287 if ($lockScreenType == 'redirect') {
288 $redirectUrl = $request->get('settings.onboard_redirect_url');
289 if (!$redirectUrl || !filter_var($redirectUrl, FILTER_VALIDATE_URL)) {
290 return $this->sendError([
291 'message' => __('Course Redirect URL is not valid', 'fluent-community'),
292 ]);
293 }
294 $existingSettings['onboard_redirect_url'] = sanitize_url($redirectUrl);
295 }
296
297 $existingSettings['custom_lock_screen'] = $lockScreenType;
298 $existingSettings['emoji'] = CustomSanitizer::sanitizeEmoji($request->get('settings.emoji', ''));
299 $existingSettings['shape_svg'] = CustomSanitizer::sanitizeSvg($request->get('settings.shape_svg', ''));
300 $existingSettings['disable_comments'] = $request->get('settings.disable_comments') === 'yes' ? 'yes' : 'no';
301 $existingSettings['hide_members_count'] = $request->get('settings.hide_members_count') === 'yes' ? 'yes' : 'no';
302 $existingSettings['hide_instructor_view'] = $request->get('settings.hide_instructor_view') === 'yes' ? 'yes' : 'no';
303 $existingSettings['show_instructor_students_count'] = $request->get('settings.show_instructor_students_count') === 'yes' ? 'yes' : 'no';
304 $existingSettings['show_paywalls'] = $request->get('settings.show_paywalls') === 'yes' ? 'yes' : 'no';
305 $existingSettings['show_welcome_banner'] = $request->get('settings.show_welcome_banner') === 'yes' ? 'yes' : 'no';
306 $existingSettings['course_layout'] = $request->get('settings.course_layout') === 'modern' ? 'modern' : 'classic';
307 $existingSettings['course_details'] = CustomSanitizer::unslashMarkdown(trim((string) $request->get('settings.course_details', '')));
308 $existingSettings['sequential_lesson_order'] = $request->get('settings.sequential_lesson_order') === 'yes' ? 'yes' : 'no';
309
310 if ($request->get('privacy') == 'public' && $existingSettings['course_type'] == 'self_paced') {
311 $existingSettings['public_lesson_view'] = $request->get('settings.public_lesson_view') == 'yes' ? 'yes' : 'no';
312 } else {
313 unset($existingSettings['public_lesson_view']);
314 }
315
316 $courseData['settings'] = $existingSettings;
317
318 $previousStatus = $course->status;
319
320 $prevCourse = clone $course;
321
322 $course->fill($courseData);
323 $dirtyFields = $course->getDirty();
324
325 if ($dirtyFields) {
326 $course->save();
327 do_action('fluent_community/course/updated', $course, $dirtyFields, $prevCourse);
328 if ($previousStatus != 'published' && $course->status == 'published') {
329 do_action('fluent_community/course/published', $course);
330 }
331 }
332
333 if ($request->get('category_ids', [])) {
334 $topicsConfig = Helper::getTopicsConfig();
335 $categoryIds = (array) $request->get('category_ids', []);
336 $categoryIds = array_slice($categoryIds, 0, $topicsConfig['max_topics_per_space']);
337 $course->syncCategories($categoryIds);
338 }
339
340 $metaSettings = $request->get('meta_settings', []);
341 if($metaSettings) {
342 foreach ($metaSettings as $metaProvider => $metaData) {
343 do_action('fluent_community/course/update_meta_settings_'.$metaProvider, $metaData, $course);
344 }
345 }
346
347 return [
348 'message' => __('Course has been updated successfully.', 'fluent-community'),
349 'course' => $course,
350 ];
351 }
352
353 public function duplicateCourse(Request $request, $courseId)
354 {
355 $original = Course::findOrFail($courseId);
356
357 $courseData = $original->toArray();
358 $courseData['title'] = $original->title . ' (Copy)';
359 $courseData['slug'] = Utility::slugify($original->slug . '-' . time());
360 $courseData['status'] = 'draft';
361 $courseData['created_by'] = get_current_user_id();
362
363 do_action('fluent_community/course/before_create', $courseData);
364
365 /** @var Course $newCourse */
366 $newCourse = $original->replicate();
367 $newCourse->title = $courseData['title'];
368 $newCourse->slug = $courseData['slug'];
369 $newCourse->status = $courseData['status'];
370 $newCourse->created_by = $courseData['created_by'];
371 $newCourse->save();
372
373 if ($original->categories) {
374 $newCourse->syncCategories($original->categories->pluck('id')->toArray());
375 }
376
377 $topics = CourseTopic::with('lessons')
378 ->where('space_id', $original->id)
379 ->get();
380
381 foreach ($topics as $topic) {
382 /** @var CourseTopic $topic */
383 $newTopic = $topic->replicate();
384 $newTopic->space_id = $newCourse->id;
385 $newTopic->save();
386
387 do_action('fluent_community/section/created', $newTopic, $newCourse);
388
389 foreach ($topic->lessons as $lesson) {
390 /** @var CourseLesson $lesson */
391 $newLesson = $lesson->replicate();
392 $newLesson->space_id = $newCourse->id;
393 $newLesson->parent_id = $newTopic->id;
394 $newLesson->save();
395 CourseHelper::copyLessonDocuments($lesson, $newLesson);
396
397 do_action('fluent_community/lesson/created', $newLesson, $newTopic);
398 }
399 }
400
401 do_action('fluent_community/course/created', $newCourse);
402
403 return [
404 'message' => __('Course duplicated successfully.', 'fluent-community'),
405 'course' => $newCourse,
406 ];
407 }
408
409 public function deleteCourse(Request $request, $courseId)
410 {
411 $course = Course::findOrFail($courseId);
412
413 do_action('fluent_community/course/before_delete', $course);
414
415 // Let's remove the reactions
416 Reaction::query()->whereHas('feed', function ($q) use ($course) {
417 $q->where('space_id', $course->id);
418 })->delete();
419
420 Comment::whereHas('post', function ($q) use ($course) {
421 $q->where('space_id', $course->id);
422 })->delete();
423
424 $courseTopics = CourseTopic::with('lessons')
425 ->where('space_id', $course->id)
426 ->get();
427
428 foreach ($courseTopics as $courseTopic) {
429 do_action('fluent_community/section/before_deleted', $courseTopic);
430
431 foreach ($courseTopic->lessons as $courseLesson) {
432 do_action('fluent_community/lesson/before_deleted', $courseLesson);
433 $courseLesson->delete();
434 }
435
436 $courseTopic->delete();
437 }
438
439 // Let's delete the student enrollments
440 SpaceUserPivot::where('space_id', $course->id)
441 ->delete();
442
443 $courseId = $course->id;
444 $course->delete();
445
446 do_action('fluent_community/course/deleted', $courseId);
447
448 return [
449 'message' => __('Course has been deleted successfully along with all the associated data', 'fluent-community'),
450 ];
451 }
452
453 public function getCourseComments(Request $request, $courseId)
454 {
455 $course = Course::findOrFail($courseId);
456
457 $comments = Comment::byContentModerationAccessStatus($this->getUser(), $course)
458 ->whereHas('post', function ($q) use ($courseId) {
459 return $q->where('space_id', $courseId);
460 })
461 ->orderBy('id', 'DESC')
462 ->with([
463 'post' => function ($q) {
464 return $q->select([ 'id', 'title', 'slug' ]);
465 },
466 'xprofile' => function ($q) {
467 $q->select(ProfileHelper::getXProfilePublicFields());
468 },
469 ])
470 ->paginate();
471
472 foreach ($comments as $comment) {
473 if ($comment->user) {
474 $comment->user->makeHidden([ 'user_email' ]);
475 }
476 $likedIds = FeedsHelper::getLikedIdsByUserFeedId($comment->post_id, get_current_user_id());
477 if ($likedIds && in_array($comment->id, $likedIds)) {
478 $comment->liked = 1;
479 }
480 }
481
482 $data = [
483 'comments' => $comments,
484 ];
485
486 return apply_filters('fluent_community/admin_course_comments_api_response', $data, $request->all());
487 }
488
489 public function getCourseStudents(Request $request, $courseId)
490 {
491 Course::findOrFail($courseId);
492
493 $search = $request->getSafe('search');
494
495 $defaultDirections = [
496 'display_name' => 'ASC',
497 'created_at' => 'DESC',
498 'last_activity' => 'DESC',
499 ];
500
501 $sortBy = $request->getSafe('sort_by', 'sanitize_text_field', 'created_at');
502 $sortColumn = in_array($sortBy, array_keys($defaultDirections), true) ? $sortBy : 'created_at';
503 $sortDir = strtoupper($request->getSafe('sort_dir', 'sanitize_text_field', ''));
504 $sortDirection = in_array($sortDir, ['ASC', 'DESC'], true) ? $sortDir : $defaultDirections[$sortColumn];
505 $orderColumn = $sortColumn === 'created_at'
506 ? 'fcom_space_user.created_at'
507 : 'fcom_xprofile.' . $sortColumn;
508
509 $publicFields = array_map(function ($field) {
510 return 'fcom_xprofile.' . $field;
511 }, ProfileHelper::getXProfilePublicFields());
512
513 $students = XProfile::searchBy($search)
514 ->whereHas('user')
515 ->with([
516 'space_pivot' => function ($q) use ($courseId) {
517 return $q->where('space_id', $courseId);
518 },
519 ])
520 ->join('fcom_space_user', function ($join) use ($courseId) {
521 $join->on('fcom_space_user.user_id', '=', 'fcom_xprofile.user_id')
522 ->where('fcom_space_user.space_id', $courseId)
523 ->where('fcom_space_user.role', 'student');
524 })
525 ->select($publicFields)
526 ->orderBy($orderColumn, $sortDirection)
527 ->paginate();
528
529 $studentUserIds = $students->pluck('user_id')->toArray();
530
531 $progressMap = CourseHelper::getBulkCourseProgress($courseId, $studentUserIds);
532
533 foreach ($students as $student) {
534 $student->progress = $progressMap[$student->user_id] ?? 0;
535 }
536
537 $data = [
538 'students' => $students,
539 ];
540
541 return apply_filters('fluent_community/admin_course_students_api_response', $data, $request->all());
542 }
543
544 public function addStudent(Request $request, $courseId)
545 {
546 $course = Course::findOrFail($courseId);
547
548 $this->validate($request->all(), [
549 'user_id' => 'required|exists:users,ID',
550 ]);
551
552 $userId = (int)$request->get('user_id');
553 $targetUser = User::findOrFail($userId);
554 $xprofile = $targetUser->syncXProfile();
555
556 if ($xprofile && $xprofile->status != 'active') {
557 return $this->sendError([
558 'message' => __('Selected user is not active', 'fluent-community'),
559 ]);
560 }
561
562 $enrolled = CourseHelper::enrollCourse($course, $userId, 'by_admin');
563
564 if (!$enrolled) {
565 return $this->sendError([
566 'message' => __('User is already added to this course.', 'fluent-community'),
567 ]);
568 }
569
570 return [
571 'message' => __('User has been added to this course', 'fluent-community'),
572 ];
573 }
574
575 public function removeStudent(Request $request, $courseId, $studentId)
576 {
577 $course = Course::findOrFail($courseId);
578
579 $student = SpaceUserPivot::bySpace($course->id)
580 ->byUser($studentId)
581 ->first();
582
583 if (!$student) {
584 return $this->sendError([
585 'message' => __('Selected user is not a student of this course', 'fluent-community'),
586 ]);
587 }
588
589 Helper::removeFromSpace($course, $studentId, 'by_admin');
590
591 return [
592 'message' => __('Student has been removed from this course', 'fluent-community'),
593 ];
594 }
595
596 public function resetStudentProgress(Request $request, $courseId, $studentId)
597 {
598 Course::findOrFail($courseId);
599
600 $pivot = SpaceUserPivot::where('space_id', $courseId)
601 ->where('user_id', $studentId)
602 ->where('role', 'student')
603 ->first();
604
605 if (!$pivot) {
606 return $this->sendError([
607 'message' => __('This student is not enrolled in this course.', 'fluent-community'),
608 ]);
609 }
610
611 if (!CourseHelper::resetCourseProgress($courseId, (int) $studentId)) {
612 return $this->sendError([
613 'message' => __("Student's progress could not be reset. Please try again.", 'fluent-community'),
614 ]);
615 }
616
617 return [
618 'message' => __("Student's progress has been reset.", 'fluent-community'),
619 ];
620 }
621
622 public function getSections(Request $request, $courseId)
623 {
624 $course = Course::findOrFail($courseId);
625 /** @var Course $course */
626
627 $sectionsQuery = CourseTopic::where('space_id', $courseId)
628 ->orderBy('priority', 'ASC')
629 ->orderBy('id', 'ASC');
630
631 if (in_array('only_published', $request->get('conditions', []))) {
632 $sectionsQuery->where('status', 'published')
633 ->with([
634 'lessons' => function ($q) {
635 $q->where('status', 'published');
636 },
637 ]);
638 }
639
640 if (empty($request->get('conditions', []))) {
641 $sectionsQuery->with([ 'lessons' ]);
642 }
643
644 $sections = $sectionsQuery->get();
645
646 $data = [
647 'sections' => $sections,
648 ];
649
650 if ($request->get('with_lock_screen')) {
651 $data['lockscreen'] = LockscreenService::getLockscreenSettings($course);
652 }
653
654 return apply_filters('fluent_community/admin_course_sections_api_response', $data, $request->all());
655 }
656
657 public function getSection(Request $request, $courseId, $topicId)
658 {
659 $topic = CourseTopic::where('space_id', $courseId)
660 ->whereHas('course', function ($query) use ($courseId) {
661 $query->where('id', $courseId);
662 })
663 ->where('id', $topicId)
664 ->with([ 'lessons' ])
665 ->firstOrFail();
666
667 $data = [
668 'topic' => $topic,
669 ];
670
671 return apply_filters('fluent_community/admin_course_section_api_response', $data, $request->all());
672 }
673
674 public function resetSectionIndexes(Request $request, $courseId)
675 {
676 $indexes = $request->get('indexes', []);
677
678 $sections = CourseTopic::where('space_id', $courseId)
679 ->whereIn('id', array_keys($indexes))
680 ->get();
681
682 foreach ($sections as $section) {
683 if (!isset($indexes[$section->id])) { continue;
684 }
685 $section->priority = $indexes[$section->id];
686 $section->save();
687 }
688
689 return [
690 'sections' => $sections,
691 'message' => __('Section indexes have been updated successfully.', 'fluent-community'),
692 ];
693 }
694
695 public function resetLessonIndexes(Request $request, $courseId, $sectionId)
696 {
697 $indexes = $request->get('indexes', []);
698
699 $lessons = CourseLesson::where('space_id', $courseId)
700 ->where('parent_id', $sectionId)
701 ->whereIn('id', array_keys($indexes))
702 ->get();
703
704 foreach ($lessons as $lesson) {
705 if (!isset($indexes[$lesson->id])) { continue;
706 }
707 $lesson->priority = $indexes[$lesson->id];
708 $lesson->save();
709 }
710
711 return [
712 'lessons' => $lessons,
713 'message' => __('Lesson indexes have been updated successfully.', 'fluent-community'),
714 ];
715 }
716
717 public function moveLesson(Request $request, $courseId)
718 {
719 $lessonId = $request->getSafe('lesson_id', 'intval');
720 $sectionId = $request->getSafe('section_id', 'intval');
721
722 Course::findOrFail($courseId);
723 $section = CourseTopic::where('space_id', $courseId)->findOrFail($sectionId);
724 $lesson = CourseLesson::where('space_id', $courseId)->findOrFail($lessonId);
725
726 $lesson->update([
727 'parent_id' => $section->id,
728 ]);
729
730 return [
731 'message' => __('Lesson has been moved successfully', 'fluent-community'),
732 ];
733 }
734
735 public function createSection(Request $request, $courseId)
736 {
737 $this->validate($request->all(), [
738 'title' => 'required',
739 ]);
740
741 $sectionData = [
742 'title' => $request->getSafe('title'),
743 'space_id' => $courseId,
744 'status' => 'published',
745 ];
746
747 $course = Course::findOrFail($courseId);
748
749 $latestPriority = CourseTopic::where('type', 'course_section')->where('space_id', $courseId)->max('priority');
750
751 $sectionData['priority'] = (int) $latestPriority + 1;
752
753 $section = CourseTopic::create($sectionData);
754
755 $section->load('lessons');
756
757 do_action('fluent_community/section/created', $section, $course);
758
759 return [
760 'message' => __('Section has been created successfully.', 'fluent-community'),
761 'section' => $section,
762 ];
763 }
764
765 public function updateSection(Request $request, $courseId, $tipicId)
766 {
767 $this->validate($request->all(), [
768 'title' => 'required',
769 'status' => 'required|in:draft,published,archived',
770 ]);
771
772 Course::findOrFail($courseId);
773
774 $topic = CourseTopic::where('space_id', $courseId)
775 ->where('id', $tipicId)
776 ->firstOrFail();
777
778
779 $topicData = [
780 'title' => $request->getSafe('title'),
781 'status' => $request->get('status'),
782 ];
783
784 $topic->update($topicData);
785
786 return [
787 'message' => __('Topic has been updated successfully.', 'fluent-community'),
788 'topic' => $topic,
789 ];
790 }
791
792 public function patchSection(Request $request, $courseId, $tipicId)
793 {
794 $course = Course::findOrFail($courseId);
795
796 $topic = CourseTopic::where('space_id', $courseId)
797 ->where('id', $tipicId)
798 ->firstOrFail();
799
800 $acceptedFields = [ 'title', 'status' ];
801
802 if ($course->getCourseType() == 'scheduled') {
803 $acceptedFields[] = 'scheduled_at';
804 } elseif ($course->getCourseType() == 'structured') {
805 $acceptedFields[] = 'reactions_count';
806 }
807
808 // Request::only() does not sanitize. Section titles currently render as escaped
809 // text, so this is hygiene rather than a live sink, but keep the stored value
810 // clean so a future v-html render cannot turn it into one.
811 $topicData = $request->only($acceptedFields);
812
813 if (isset($topicData['title'])) {
814 $topicData['title'] = sanitize_text_field($topicData['title']);
815 }
816
817 if (isset($topicData['status'])) {
818 $topicData['status'] = sanitize_text_field($topicData['status']);
819 }
820
821 if (!empty($topicData['scheduled_at'])) {
822 $topic->reactions_count = 0;
823 } elseif (isset($topicData['reactions_count'])) {
824 $topic->scheduled_at = null;
825 $topic->reactions_count = $topicData['reactions_count'];
826 }
827
828 $topicData = apply_filters('fluent_community/section/update_data', $topicData, $course, $topic, $request->all());
829
830 if (is_wp_error($topicData)) {
831 return $this->sendError($topicData->get_error_messages());
832 }
833
834 $topic->fill($topicData);
835
836 $scheduledAtDirty = $topic->isDirty('scheduled_at');
837 $reactionsCountDirty = $topic->isDirty('reactions_count');
838
839 $topic->save();
840
841 if ($scheduledAtDirty) {
842 do_action('fluent_community/section/scheduled_at_updated', $course, $topic);
843 }
844 if ($reactionsCountDirty) {
845 do_action('fluent_community/section/reactions_count_updated', $course, $topic);
846 }
847
848 return [
849 'message' => __('Topic has been updated successfully.', 'fluent-community'),
850 'topic' => $topic,
851 ];
852 }
853
854 public function copySection(Request $request, $toCourseId)
855 {
856 $sectionId = $request->getSafe('section_id', 'intval');
857 $fromCourseId = $request->getSafe('from_course_id', 'intval');
858
859 $fromCourse = Course::findOrFail($fromCourseId);
860
861 if (!$fromCourse->isCourseAdmin()) {
862 return $this->sendError([
863 'message' => __('You do not have permission to access this course', 'fluent-community'),
864 ]);
865 }
866
867 /** @var CourseTopic $originalSection */
868 $originalSection = CourseTopic::where('id', $sectionId)
869 ->where('space_id', $fromCourseId)
870 ->firstOrFail();
871
872 $toCourse = Course::findOrFail($toCourseId);
873
874 $latestPriority = CourseTopic::where('type', 'course_section')->where('space_id', $toCourse->id)->max('priority');
875
876 /** @var CourseTopic $newSection */
877 $newSection = $originalSection->replicate();
878 $newSection->space_id = $toCourse->id;
879 $newSection->priority = (int) $latestPriority + 1;
880 $newSection->save();
881
882 do_action('fluent_community/section/created', $newSection, $toCourse);
883
884 $originalLessons = CourseLesson::where('parent_id', $originalSection->id)->get();
885 foreach ($originalLessons as $lesson) {
886 /** @var CourseLesson $lesson */
887 $newLesson = $lesson->replicate();
888 $newLesson->space_id = $toCourse->id;
889 $newLesson->parent_id = $newSection->id;
890 $newLesson->save();
891 CourseHelper::copyLessonDocuments($lesson, $newLesson);
892
893 do_action('fluent_community/lesson/created', $newLesson, $newSection);
894 }
895
896 $newSection->load('lessons');
897
898 return [
899 'message' => __('Section has been copied to the selected course', 'fluent-community'),
900 'section' => $newSection,
901 ];
902 }
903
904 public function deleteSection(Request $request, $courseId, $sectionId)
905 {
906 $topic = CourseTopic::where([
907 'id' => $sectionId,
908 'space_id' => $courseId,
909 ])->firstOrFail();
910
911 do_action('fluent_community/section/before_deleted', $topic);
912
913 $topic->delete();
914
915 $lessons = CourseLesson::where([
916 'parent_id' => $sectionId,
917 'space_id' => $courseId,
918 ])->get();
919
920 foreach ($lessons as $lesson) {
921 do_action('fluent_community/lesson/before_deleted', $lesson);
922 $lesson->delete();
923 }
924
925 return [
926 'message' => __('Section has been deleted successfully.', 'fluent-community'),
927 ];
928 }
929
930 public function getLessons(Request $request, $courseId)
931 {
932 Course::findOrFail($courseId);
933
934 $lessons = CourseLesson::where('space_id', $courseId)
935 ->orderBy('priority', 'ASC')
936 ->orderBy('id', 'ASC');
937
938 $topicId = (int)$request->get('topic_id');
939
940 if ($topicId) {
941 $lessons = $lessons->where('parent_id', $topicId);
942 }
943
944 $lessons = $lessons->get();
945
946 $data = [
947 'lessons' => $lessons,
948 ];
949
950 return apply_filters('fluent_community/admin_course_lessons_api_response', $data, $request->all());
951 }
952
953 public function getLesson(Request $request, $courseId, $lessonId)
954 {
955 $lesson = CourseLesson::whereHas('course', function ($query) use ($courseId) {
956 $query->where('id', $courseId);
957 })
958 ->where('id', $lessonId)
959 ->with([ 'topic', 'course' ])
960 ->firstOrFail();
961
962 $data = [
963 'lesson' => $lesson,
964 ];
965
966 return apply_filters('fluent_community/admin_course_lesson_api_response', $data, $request->all());
967 }
968
969 /**
970 * Expects `title` and `section_id` at the top level of the request.
971 */
972 public function createLesson(Request $request, $courseId)
973 {
974 $this->validate($request->all(), [
975 'title' => 'required',
976 'section_id' => 'required',
977 ]);
978
979 $sectionId = (int)$request->get('section_id');
980
981 $topic = CourseTopic::whereHas('course', function ($query) use ($courseId) {
982 $query->where('id', $courseId);
983 })
984 ->where('id', $sectionId)
985 ->firstOrFail();
986
987 $lessonData = [
988 'title' => $request->getSafe('title'),
989 'parent_id' => $topic->id,
990 'space_id' => $courseId,
991 'status' => 'draft',
992 ];
993
994 $latestPriority = CourseLesson::where('type', 'course_lesson')
995 ->where('parent_id', $sectionId)
996 ->where('space_id', $courseId)
997 ->max('priority');
998
999 $lessonData['priority'] = (int) $latestPriority + 1;
1000
1001 $lessonData = apply_filters('fluent_community/lesson/create_data', $lessonData, $request);
1002
1003 $lesson = CourseLesson::create($lessonData);
1004
1005 $lesson = CourseLesson::findOrFail($lesson->id);
1006
1007 do_action('fluent_community/lesson/created', $lesson, $topic);
1008
1009 return [
1010 'message' => __('Lesson has been created successfully.', 'fluent-community'),
1011 'lesson' => $lesson,
1012 ];
1013 }
1014
1015 /**
1016 * Expects the fields nested under `lesson`, referencing the section as `parent_id`.
1017 */
1018 public function updateLesson(Request $request, $courseId, $lessionId)
1019 {
1020 Course::findOrFail($courseId);
1021
1022 $lessonData = (array)$request->get('lesson');
1023
1024 $this->validate($lessonData, [
1025 'title' => 'required',
1026 'parent_id' => 'required',
1027 'status' => 'required|in:draft,published,archived',
1028 ]);
1029
1030 CourseTopic::whereHas('course', function ($query) use ($courseId) {
1031 $query->where('id', $courseId);
1032 })
1033 ->where('id', $lessonData['parent_id'])
1034 ->firstOrFail();
1035
1036 /** @var CourseLesson $lesson */
1037 $lesson = CourseLesson::where('id', $lessionId)
1038 ->where('space_id', $courseId)
1039 ->firstOrFail();
1040
1041 $previousStatus = $lesson->status;
1042
1043 $updatedMeta = CourseHelper::sanitizeLessonMeta(Arr::get($lessonData, 'meta', []), $lesson);
1044 $updatedMeta['document_ids'] = Arr::get($lesson->meta, 'document_ids', []);
1045
1046 if ($mediaId = Arr::get($updatedMeta, 'featured_image_id')) { // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.FoundInControlStructure
1047 if (!$lesson->isQuizType()) {
1048 $media = wp_get_attachment_image_url($mediaId);
1049 $lesson->featured_image = $media ? $media : null;
1050 } else {
1051 $media = Helper::getMediaFromUrl(sanitize_url($mediaId));
1052 if ($media && !$media->is_active) {
1053 Helper::removeMediaByUrl($lesson->featured_image, $lesson->id);
1054 $lesson->featured_image = $media->public_url;
1055 $media->update([
1056 'is_active' => true,
1057 'user_id' => get_current_user_id(),
1058 'sub_object_id' => $lesson->id,
1059 'object_source' => 'quiz_thumbnail_' . $lesson->id,
1060 ]);
1061 }
1062 }
1063 } else {
1064 Helper::removeMediaByUrl($lesson->featured_image, $lesson->id);
1065 $lesson->featured_image = null;
1066 }
1067
1068 $updateData = array_filter([
1069 'title' => sanitize_text_field(Arr::get($lessonData, 'title')),
1070 'status' => Arr::get($lessonData, 'status'),
1071 'meta' => wp_parse_args($updatedMeta, $lesson->meta),
1072 ]);
1073
1074 // message bypasses array_filter so an emptied lesson body still saves
1075 if (array_key_exists('message', $lessonData)) {
1076 $updateData['message'] = CourseHelper::santizeLessonBody((string) Arr::get($lessonData, 'message'));
1077 }
1078
1079 $updateData = apply_filters('fluent_community/lesson/update_data', $updateData, $lesson);
1080
1081 $lesson->fill($updateData);
1082 $dirtyFields = $lesson->getDirty();
1083
1084 if ($dirtyFields) {
1085 $lesson->save();
1086 $isNewlyPublished = $lesson->status === 'published' && $previousStatus !== 'published';
1087 do_action('fluent_community/lesson/updated', $lesson, $dirtyFields, $isNewlyPublished);
1088
1089 if ($isNewlyPublished) {
1090 do_action('fluent_community/lesson/published', $lesson);
1091 }
1092 }
1093
1094 do_action('fluent_community/lesson/additional_media_updated', $request->all(), $lesson, $updateData);
1095
1096 return [
1097 'message' => __('Lesson has been updated successfully.', 'fluent-community'),
1098 'lesson' => $lesson,
1099 ];
1100 }
1101
1102 public function patchLesson(Request $request, $courseId, $lessionId)
1103 {
1104 $lesson = CourseLesson::whereHas('course', function ($query) use ($courseId) {
1105 $query->where('id', $courseId);
1106 })
1107 ->where('id', $lessionId)
1108 ->firstOrFail();
1109
1110 $previousStatus = $lesson->status;
1111
1112 $acceptedFields = [ 'title', 'status', 'slug' ];
1113
1114 // empty title/slug/status must not overwrite, but a literal "0" is a valid value
1115 // Request::only() is a plain array pick with no sanitization, so each field is
1116 // sanitized here the same way createLesson/updateLesson do it. The title is
1117 // rendered with v-html in the course views, so it must not carry markup.
1118 $lessonData = array_filter($request->only($acceptedFields), function ($value) {
1119 return $value !== null && $value !== '';
1120 });
1121
1122 if (isset($lessonData['title'])) {
1123 $lessonData['title'] = sanitize_text_field($lessonData['title']);
1124 }
1125
1126 if (isset($lessonData['slug'])) {
1127 // sanitize_title() alone let an author set a slug already taken by a
1128 // sibling lesson, which getLessonBySlug() then resolves arbitrarily.
1129 $lessonData['slug'] = CourseLesson::uniqueSlug(
1130 $lessonData['slug'],
1131 $lesson->space_id,
1132 $lesson->id,
1133 Arr::get($lessonData, 'title', $lesson->title)
1134 );
1135 }
1136
1137 if (isset($lessonData['status']) && !in_array($lessonData['status'], ['draft', 'published', 'archived'], true)) {
1138 unset($lessonData['status']);
1139 }
1140
1141 if (Arr::get($lessonData, 'status') === 'published' && $lesson->status !== 'published') {
1142 if (empty($lesson->scheduled_at)) {
1143 $lessonData['scheduled_at'] = current_time('mysql');
1144 }
1145 }
1146
1147 // message bypasses the empty-value filter above so an emptied lesson body still saves
1148 if ($request->exists('message')) {
1149 $lessonData['message'] = CourseHelper::santizeLessonBody((string) $request->get('message'));
1150 }
1151
1152 if (!empty($lessonData)) {
1153 $lesson->fill($lessonData);
1154 $dirtyFields = $lesson->getDirty();
1155
1156 if ($dirtyFields) {
1157 $lesson->save();
1158
1159 $isNewlyPublished = $lesson->status === 'published' && $previousStatus !== 'published';
1160 do_action('fluent_community/lesson/updated', $lesson, $dirtyFields, $isNewlyPublished);
1161
1162 if ($isNewlyPublished) {
1163 do_action('fluent_community/lesson/published', $lesson);
1164 }
1165 }
1166 }
1167
1168 return [
1169 'message' => __('Lesson has been updated successfully.', 'fluent-community'),
1170 'lesson' => $lesson,
1171 ];
1172 }
1173
1174 public function deleteLesson(Request $request, $courseId, $lessionId)
1175 {
1176 $lesson = CourseLesson::whereHas('course', function ($query) use ($courseId) {
1177 $query->where('id', $courseId);
1178 })
1179 ->where('id', $lessionId)
1180 ->firstOrFail();
1181
1182 do_action('fluent_community/lesson/before_deleted', $lesson);
1183
1184 $lesson->delete();
1185
1186 return [
1187 'message' => __('Lesson has been deleted successfully.', 'fluent-community'),
1188 ];
1189 }
1190
1191 public function duplicateLesson(Request $request, $courseId, $lessonId)
1192 {
1193 Course::findOrFail($courseId);
1194
1195 $lesson = CourseLesson::where('id', $lessonId)
1196 ->where('space_id', $courseId)
1197 ->firstOrFail();
1198
1199 $siblings = CourseLesson::where('parent_id', $lesson->parent_id)
1200 ->where('space_id', $courseId)
1201 ->orderBy('priority', 'ASC')
1202 ->orderBy('id', 'ASC')
1203 ->get();
1204
1205 $sourceIndex = $siblings->search(function ($sibling) use ($lesson) {
1206 return (int)$sibling->id === (int)$lesson->id;
1207 });
1208
1209 if ($sourceIndex === false) {
1210 $sourceIndex = $siblings->count() - 1;
1211 }
1212
1213 $existingTitles = $siblings->pluck('title')->toArray();
1214 $duplicateTitle = $lesson->title . ' (Copy)';
1215 if (in_array($duplicateTitle, $existingTitles, true)) {
1216 $counter = 2;
1217 while (in_array($lesson->title . ' (Copy ' . $counter . ')', $existingTitles, true)) {
1218 ++$counter;
1219 }
1220 $duplicateTitle = $lesson->title . ' (Copy ' . $counter . ')';
1221 }
1222
1223 /** @var CourseLesson $newLesson */
1224 $newLesson = $lesson->replicate();
1225 $newLesson->title = $duplicateTitle;
1226 $newLesson->slug = null;
1227 $newLesson->priority = $sourceIndex + 1;
1228 $newLesson->save();
1229
1230 $orderedIds = $siblings->pluck('id')->toArray();
1231 array_splice($orderedIds, $sourceIndex + 1, 0, [ $newLesson->id ]);
1232
1233 foreach ($orderedIds as $index => $siblingId) {
1234 CourseLesson::where('id', $siblingId)->update([ 'priority' => $index ]);
1235 }
1236
1237 $newLesson = CourseLesson::findOrFail($newLesson->id);
1238
1239 CourseHelper::copyLessonDocuments($lesson, $newLesson);
1240
1241 do_action('fluent_community/lesson/created', $newLesson, $newLesson->topic);
1242 do_action('fluent_community/lesson/duplicated', $newLesson, $lesson);
1243
1244 return [
1245 'message' => __('Lesson has been duplicated successfully.', 'fluent-community'),
1246 'lesson' => $newLesson,
1247 ];
1248 }
1249
1250 public function getOtherUsers(Request $request, $courseId)
1251 {
1252 $selects = [
1253 'ID',
1254 'display_name',
1255 ];
1256
1257 if (current_user_can('list_users')) {
1258 $selects[] = 'user_email';
1259 }
1260
1261 $userQuery = User::select([ 'ID' ])
1262 ->whereDoesntHave('space_pivot', function ($q) use ($courseId) {
1263 $q->where('space_id', $courseId);
1264 })
1265 ->limit(100)
1266 ->searchBy($request->getSafe('search'));
1267
1268 if (is_multisite()) {
1269 global $wpdb;
1270 $blogId = get_current_blog_id();
1271 $blogPrefix = $wpdb->get_blog_prefix($blogId);
1272 $userQuery->whereHas('usermeta', function ($q) use ($blogPrefix) {
1273 $q->where('meta_key', $blogPrefix . 'capabilities');
1274 });
1275 }
1276
1277 $userIds = $userQuery->get()
1278 ->pluck('ID')
1279 ->toArray();
1280
1281 $users = User::select($selects)
1282 ->whereIn('ID', $userIds)
1283 ->paginate(100);
1284
1285 $data = [
1286 'users' => $users,
1287 ];
1288
1289 return apply_filters('fluent_community/admin_course_non_members_api_response', $data, $request->all());
1290 }
1291
1292 public function updateLinks(Request $request, $id)
1293 {
1294 $course = Course::findOrFail($id);
1295 $links = $request->get('links', []);
1296
1297 $links = array_map(function ($link) {
1298 return CustomSanitizer::santizeLinkItem($link);
1299 }, $links);
1300
1301 $settings = $course->settings;
1302 $settings['links'] = $links;
1303 $course->settings = $settings;
1304 $course->save();
1305
1306 return [
1307 'message' => __('Links have been updated for the course', 'fluent-community'),
1308 'links' => $links,
1309 ];
1310 }
1311
1312 public function getMetaSettings(Request $request, $id)
1313 {
1314 $course = Course::findOrFail($id);
1315 $metaSettings = apply_filters('fluent_community/course/meta_fields', [], $course, $request->all());
1316
1317 if (!$metaSettings) {
1318 return [
1319 'meta_settings' => null,
1320 ];
1321 }
1322
1323 return [
1324 'meta_settings' => $metaSettings,
1325 ];
1326 }
1327
1328 public function getOtherInstructors(Request $request, $courseId)
1329 {
1330 $search = $request->getSafe('search');
1331
1332 Course::findOrFail($courseId);
1333
1334 $selects = [
1335 'ID',
1336 'display_name',
1337 ];
1338
1339 if (current_user_can('list_users')) {
1340 $selects[] = 'user_email';
1341 }
1342
1343 $instructors = User::select($selects)
1344 ->limit(100)
1345 ->searchBy($search)
1346 ->get();
1347
1348 $data = [
1349 'instructors' => $instructors,
1350 ];
1351
1352 return apply_filters('fluent_community/admin_course_other_instructors_api_response', $data, $request->all());
1353 }
1354 }
1355