PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.7.7
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.7.7
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 1.1.0 All 77 releases
fluent-community / Modules / Course / Http / Controllers / CourseAdminController.php

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

1,291 lines 45.0 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($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($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($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($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 foreach ($topic->lessons as $lesson) {
388 /** @var CourseLesson $lesson */
389 $newLesson = $lesson->replicate();
390 $newLesson->space_id = $newCourse->id;
391 $newLesson->parent_id = $newTopic->id;
392 $newLesson->save();
393 CourseHelper::copyLessonDocuments($lesson, $newLesson);
394 }
395 }
396
397 do_action('fluent_community/course/created', $newCourse);
398
399 return [
400 'message' => __('Course duplicated successfully.', 'fluent-community'),
401 'course' => $newCourse,
402 ];
403 }
404
405 public function deleteCourse(Request $request, $courseId)
406 {
407 $course = Course::findOrFail($courseId);
408
409 do_action('fluent_community/course/before_delete', $course);
410
411 // Let's remove the reactions
412 Reaction::query()->whereHas('feed', function ($q) use ($course) {
413 $q->where('space_id', $course->id);
414 })->delete();
415
416 Comment::whereHas('post', function ($q) use ($course) {
417 $q->where('space_id', $course->id);
418 })->delete();
419
420 $courseTopics = CourseTopic::with('lessons')
421 ->where('space_id', $course->id)
422 ->get();
423
424 foreach ($courseTopics as $courseTopic) {
425 do_action('fluent_community/section/before_deleted', $courseTopic);
426
427 foreach ($courseTopic->lessons as $courseLesson) {
428 do_action('fluent_community/lesson/before_deleted', $courseLesson);
429 $courseLesson->delete();
430 }
431
432 $courseTopic->delete();
433 }
434
435 // Let's delete the student enrollments
436 SpaceUserPivot::where('space_id', $course->id)
437 ->delete();
438
439 $courseId = $course->id;
440 $course->delete();
441
442 do_action('fluent_community/course/deleted', $courseId);
443
444 return [
445 'message' => __('Course has been deleted successfully along with all the associated data', 'fluent-community'),
446 ];
447 }
448
449 public function getCourseComments(Request $request, $courseId)
450 {
451 $course = Course::findOrFail($courseId);
452
453 $comments = Comment::byContentModerationAccessStatus($this->getUser(), $course)
454 ->whereHas('post', function ($q) use ($courseId) {
455 return $q->where('space_id', $courseId);
456 })
457 ->orderBy('id', 'DESC')
458 ->with([
459 'post' => function ($q) {
460 return $q->select([ 'id', 'title', 'slug' ]);
461 },
462 'xprofile' => function ($q) {
463 $q->select(ProfileHelper::getXProfilePublicFields());
464 },
465 ])
466 ->paginate();
467
468 foreach ($comments as $comment) {
469 if ($comment->user) {
470 $comment->user->makeHidden([ 'user_email' ]);
471 }
472 $likedIds = FeedsHelper::getLikedIdsByUserFeedId($comment->post_id, get_current_user_id());
473 if ($likedIds && in_array($comment->id, $likedIds)) {
474 $comment->liked = 1;
475 }
476 }
477
478 $data = [
479 'comments' => $comments,
480 ];
481
482 return apply_filters('fluent_community/admin_course_comments_api_response', $data, $request->all());
483 }
484
485 public function getCourseStudents(Request $request, $courseId)
486 {
487 Course::findOrFail($courseId);
488
489 $search = $request->getSafe('search');
490
491 $students = XProfile::whereHas('space_pivot', function ($q) use ($courseId) {
492 return $q->where('space_id', $courseId)
493 ->where('role', 'student');
494 })
495 ->searchBy($search)
496 ->whereHas('user')
497 ->with([
498 'space_pivot' => function ($q) use ($courseId) {
499 return $q->where('space_id', $courseId);
500 },
501 ])
502 ->select(ProfileHelper::getXProfilePublicFields())
503 ->paginate();
504
505 $studentUserIds = $students->pluck('user_id')->toArray();
506
507 $progressMap = CourseHelper::getBulkCourseProgress($courseId, $studentUserIds);
508
509 foreach ($students as $student) {
510 $student->progress = $progressMap[$student->user_id] ?? 0;
511 }
512
513 $data = [
514 'students' => $students,
515 ];
516
517 return apply_filters('fluent_community/admin_course_students_api_response', $data, $request->all());
518 }
519
520 public function addStudent(Request $request, $courseId)
521 {
522 $course = Course::findOrFail($courseId);
523
524 $this->validate($request->all(), [
525 'user_id' => 'required|exists:users,ID',
526 ]);
527
528 $userId = (int)$request->get('user_id');
529 $targetUser = User::findOrFail($userId);
530 $xprofile = $targetUser->syncXProfile();
531
532 if ($xprofile && $xprofile->status != 'active') {
533 return $this->sendError([
534 'message' => __('Selected user is not active', 'fluent-community'),
535 ]);
536 }
537
538 $enrolled = CourseHelper::enrollCourse($course, $userId, 'by_admin');
539
540 if (!$enrolled) {
541 return $this->sendError([
542 'message' => __('User is already added to this course.', 'fluent-community'),
543 ]);
544 }
545
546 return [
547 'message' => __('User has been added to this course', 'fluent-community'),
548 ];
549 }
550
551 public function removeStudent(Request $request, $courseId, $studentId)
552 {
553 $course = Course::findOrFail($courseId);
554
555 $student = SpaceUserPivot::bySpace($course->id)
556 ->byUser($studentId)
557 ->first();
558
559 if (!$student) {
560 return $this->sendError([
561 'message' => __('Selected user is not a student of this course', 'fluent-community'),
562 ]);
563 }
564
565 Helper::removeFromSpace($course, $studentId, 'by_admin');
566
567 return [
568 'message' => __('Student has been removed from this course', 'fluent-community'),
569 ];
570 }
571
572 public function resetStudentProgress(Request $request, $courseId, $studentId)
573 {
574 Course::findOrFail($courseId);
575
576 $pivot = SpaceUserPivot::where('space_id', $courseId)
577 ->where('user_id', $studentId)
578 ->where('role', 'student')
579 ->first();
580
581 if (!$pivot) {
582 return $this->sendError([
583 'message' => __('This student is not enrolled in this course.', 'fluent-community'),
584 ]);
585 }
586
587 CourseHelper::resetCourseProgress($courseId, (int) $studentId);
588
589 return [
590 'message' => __("Student's progress has been reset.", 'fluent-community'),
591 ];
592 }
593
594 public function getSections(Request $request, $courseId)
595 {
596 $course = Course::findOrFail($courseId);
597 /** @var Course $course */
598
599 $sectionsQuery = CourseTopic::where('space_id', $courseId)
600 ->orderBy('priority', 'ASC')
601 ->orderBy('id', 'ASC');
602
603 if (in_array('only_published', $request->get('conditions', []))) {
604 $sectionsQuery->where('status', 'published')
605 ->with([
606 'lessons' => function ($q) {
607 $q->where('status', 'published');
608 },
609 ]);
610 }
611
612 if (empty($request->get('conditions', []))) {
613 $sectionsQuery->with([ 'lessons' ]);
614 }
615
616 $sections = $sectionsQuery->get();
617
618 $data = [
619 'sections' => $sections,
620 ];
621
622 if ($request->get('with_lock_screen')) {
623 $data['lockscreen'] = LockscreenService::getLockscreenSettings($course);
624 }
625
626 return apply_filters('fluent_community/admin_course_sections_api_response', $data, $request->all());
627 }
628
629 public function getSection(Request $request, $courseId, $topicId)
630 {
631 $topic = CourseTopic::where('space_id', $courseId)
632 ->whereHas('course', function ($query) use ($courseId) {
633 $query->where('id', $courseId);
634 })
635 ->where('id', $topicId)
636 ->with([ 'lessons' ])
637 ->firstOrFail();
638
639 $data = [
640 'topic' => $topic,
641 ];
642
643 return apply_filters('fluent_community/admin_course_section_api_response', $data, $request->all());
644 }
645
646 public function resetSectionIndexes(Request $request, $courseId)
647 {
648 $indexes = $request->get('indexes', []);
649
650 $sections = CourseTopic::where('space_id', $courseId)
651 ->whereIn('id', array_keys($indexes))
652 ->get();
653
654 foreach ($sections as $section) {
655 if (!isset($indexes[$section->id])) { continue;
656 }
657 $section->priority = $indexes[$section->id];
658 $section->save();
659 }
660
661 return [
662 'sections' => $sections,
663 'message' => __('Section indexes have been updated successfully.', 'fluent-community'),
664 ];
665 }
666
667 public function resetLessonIndexes(Request $request, $courseId, $sectionId)
668 {
669 $indexes = $request->get('indexes', []);
670
671 $lessons = CourseLesson::where('space_id', $courseId)
672 ->where('parent_id', $sectionId)
673 ->whereIn('id', array_keys($indexes))
674 ->get();
675
676 foreach ($lessons as $lesson) {
677 if (!isset($indexes[$lesson->id])) { continue;
678 }
679 $lesson->priority = $indexes[$lesson->id];
680 $lesson->save();
681 }
682
683 return [
684 'lessons' => $lessons,
685 'message' => __('Lesson indexes have been updated successfully.', 'fluent-community'),
686 ];
687 }
688
689 public function moveLesson(Request $request, $courseId)
690 {
691 $lessonId = $request->getSafe('lesson_id', 'intval');
692 $sectionId = $request->getSafe('section_id', 'intval');
693
694 Course::findOrFail($courseId);
695 CourseTopic::findOrFail($sectionId);
696
697 $lesson = CourseLesson::findOrFail($lessonId);
698
699 $lesson->update([
700 'parent_id' => $sectionId,
701 ]);
702
703 return [
704 'message' => __('Lesson has been moved successfully', 'fluent-community'),
705 ];
706 }
707
708 public function createSection(Request $request, $courseId)
709 {
710 $this->validate($request->all(), [
711 'title' => 'required',
712 ]);
713
714 $sectionData = [
715 'title' => $request->getSafe('title'),
716 'space_id' => $courseId,
717 'status' => 'published',
718 ];
719
720 Course::findOrFail($courseId);
721
722 $latestPriority = CourseTopic::where('type', 'course_section')->where('space_id', $courseId)->max('priority');
723
724 $sectionData['priority'] = (int) $latestPriority + 1;
725
726 $section = CourseTopic::create($sectionData);
727
728 $section->load('lessons');
729
730 return [
731 'message' => __('Section has been created successfully.', 'fluent-community'),
732 'section' => $section,
733 ];
734 }
735
736 public function updateSection(Request $request, $courseId, $tipicId)
737 {
738 $this->validate($request->all(), [
739 'title' => 'required',
740 'status' => 'required|in:draft,published,archived',
741 ]);
742
743 Course::findOrFail($courseId);
744
745 $topic = CourseTopic::where('space_id', $courseId)
746 ->where('id', $tipicId)
747 ->firstOrFail();
748
749
750 $topicData = [
751 'title' => $request->getSafe('title'),
752 'status' => $request->get('status'),
753 ];
754
755 $topic->update($topicData);
756
757 return [
758 'message' => __('Topic has been updated successfully.', 'fluent-community'),
759 'topic' => $topic,
760 ];
761 }
762
763 public function patchSection(Request $request, $courseId, $tipicId)
764 {
765 $course = Course::findOrFail($courseId);
766
767 $topic = CourseTopic::where('space_id', $courseId)
768 ->where('id', $tipicId)
769 ->firstOrFail();
770
771 $acceptedFields = [ 'title', 'status' ];
772
773 if ($course->getCourseType() == 'scheduled') {
774 $acceptedFields[] = 'scheduled_at';
775 } elseif ($course->getCourseType() == 'structured') {
776 $acceptedFields[] = 'reactions_count';
777 }
778
779 // Request::only() does not sanitize. Section titles currently render as escaped
780 // text, so this is hygiene rather than a live sink, but keep the stored value
781 // clean so a future v-html render cannot turn it into one.
782 $topicData = $request->only($acceptedFields);
783
784 if (isset($topicData['title'])) {
785 $topicData['title'] = sanitize_text_field($topicData['title']);
786 }
787
788 if (isset($topicData['status'])) {
789 $topicData['status'] = sanitize_text_field($topicData['status']);
790 }
791
792 if (!empty($topicData['scheduled_at'])) {
793 $topic->reactions_count = 0;
794 } elseif (isset($topicData['reactions_count'])) {
795 $topic->scheduled_at = null;
796 $topic->reactions_count = $topicData['reactions_count'];
797 }
798
799 $topicData = apply_filters('fluent_community/section/update_data', $topicData, $course, $topic, $request->all());
800
801 if (is_wp_error($topicData)) {
802 return $this->sendError($topicData->get_error_messages());
803 }
804
805 $topic->fill($topicData);
806
807 $scheduledAtDirty = $topic->isDirty('scheduled_at');
808 $reactionsCountDirty = $topic->isDirty('reactions_count');
809
810 $topic->save();
811
812 if ($scheduledAtDirty) {
813 do_action('fluent_community/section/scheduled_at_updated', $course, $topic);
814 }
815 if ($reactionsCountDirty) {
816 do_action('fluent_community/section/reactions_count_updated', $course, $topic);
817 }
818
819 return [
820 'message' => __('Topic has been updated successfully.', 'fluent-community'),
821 'topic' => $topic,
822 ];
823 }
824
825 public function copySection(Request $request, $toCourseId)
826 {
827 $sectionId = $request->getSafe('section_id', 'intval');
828 $fromCourseId = $request->getSafe('from_course_id', 'intval');
829
830 $fromCourse = Course::findOrFail($fromCourseId);
831
832 if (!$fromCourse->isCourseAdmin()) {
833 return $this->sendError([
834 'message' => __('You do not have permission to access this course', 'fluent-community'),
835 ]);
836 }
837
838 /** @var CourseTopic $originalSection */
839 $originalSection = CourseTopic::where('id', $sectionId)
840 ->where('space_id', $fromCourseId)
841 ->firstOrFail();
842
843 $toCourse = Course::findOrFail($toCourseId);
844
845 $latestPriority = CourseTopic::where('type', 'course_section')->where('space_id', $toCourse->id)->max('priority');
846
847 /** @var CourseTopic $newSection */
848 $newSection = $originalSection->replicate();
849 $newSection->space_id = $toCourse->id;
850 $newSection->priority = (int) $latestPriority + 1;
851 $newSection->save();
852
853 $originalLessons = CourseLesson::where('parent_id', $originalSection->id)->get();
854 foreach ($originalLessons as $lesson) {
855 /** @var CourseLesson $lesson */
856 $newLesson = $lesson->replicate();
857 $newLesson->space_id = $toCourse->id;
858 $newLesson->parent_id = $newSection->id;
859 $newLesson->save();
860 CourseHelper::copyLessonDocuments($lesson, $newLesson);
861 }
862
863 $newSection->load('lessons');
864
865 return [
866 'message' => __('Section has been copied to the selected course', 'fluent-community'),
867 'section' => $newSection,
868 ];
869 }
870
871 public function deleteSection(Request $request, $courseId, $sectionId)
872 {
873 $topic = CourseTopic::where([
874 'id' => $sectionId,
875 'space_id' => $courseId,
876 ])->firstOrFail();
877
878 do_action('fluent_community/section/before_deleted', $topic);
879
880 $topic->delete();
881
882 $lessons = CourseLesson::where([
883 'parent_id' => $sectionId,
884 'space_id' => $courseId,
885 ])->get();
886
887 foreach ($lessons as $lesson) {
888 do_action('fluent_community/lesson/before_deleted', $lesson);
889 $lesson->delete();
890 }
891
892 return [
893 'message' => __('Section has been deleted successfully.', 'fluent-community'),
894 ];
895 }
896
897 public function getLessons(Request $request, $courseId)
898 {
899 Course::findOrFail($courseId);
900
901 $lessons = CourseLesson::where('space_id', $courseId)
902 ->orderBy('priority', 'ASC')
903 ->orderBy('id', 'ASC');
904
905 $topicId = (int)$request->get('topic_id');
906
907 if ($topicId) {
908 $lessons = $lessons->where('parent_id', $topicId);
909 }
910
911 $lessons = $lessons->get();
912
913 $data = [
914 'lessons' => $lessons,
915 ];
916
917 return apply_filters('fluent_community/admin_course_lessons_api_response', $data, $request->all());
918 }
919
920 public function getLesson(Request $request, $courseId, $lessonId)
921 {
922 $lesson = CourseLesson::whereHas('course', function ($query) use ($courseId) {
923 $query->where('id', $courseId);
924 })
925 ->where('id', $lessonId)
926 ->with([ 'topic', 'course' ])
927 ->firstOrFail();
928
929 $data = [
930 'lesson' => $lesson,
931 ];
932
933 return apply_filters('fluent_community/admin_course_lesson_api_response', $data, $request->all());
934 }
935
936 public function createLesson(Request $request, $courseId)
937 {
938 $this->validate($request->all(), [
939 'title' => 'required',
940 'section_id' => 'required',
941 ]);
942
943 $sectionId = (int)$request->get('section_id');
944
945 $topic = CourseTopic::whereHas('course', function ($query) use ($courseId) {
946 $query->where('id', $courseId);
947 })
948 ->where('id', $sectionId)
949 ->firstOrFail();
950
951 $lessonData = [
952 'title' => $request->getSafe('title'),
953 'parent_id' => $topic->id,
954 'space_id' => $courseId,
955 'status' => 'draft',
956 ];
957
958 $latestPriority = CourseLesson::where('type', 'course_lesson')
959 ->where('parent_id', $sectionId)
960 ->where('space_id', $courseId)
961 ->max('priority');
962
963 $lessonData['priority'] = (int) $latestPriority + 1;
964
965 $lessonData = apply_filters('fluent_community/lesson/create_data', $lessonData, $request);
966
967 $lesson = CourseLesson::create($lessonData);
968
969 $lesson = CourseLesson::findOrFail($lesson->id);
970
971 return [
972 'message' => __('Lesson has been created successfully.', 'fluent-community'),
973 'lesson' => $lesson,
974 ];
975 }
976
977 public function updateLesson(Request $request, $courseId, $lessionId)
978 {
979 Course::findOrFail($courseId);
980
981 $lessonData = $request->get('lesson');
982
983 $this->validate($lessonData, [
984 'title' => 'required',
985 'parent_id' => 'required',
986 'status' => 'required|in:draft,published,archived',
987 ]);
988
989 CourseTopic::whereHas('course', function ($query) use ($courseId) {
990 $query->where('id', $courseId);
991 })
992 ->where('id', $lessonData['parent_id'])
993 ->firstOrFail();
994
995 /** @var CourseLesson $lesson */
996 $lesson = CourseLesson::where('id', $lessionId)
997 ->where('space_id', $courseId)
998 ->firstOrFail();
999
1000 $previousStatus = $lesson->status;
1001
1002 $updatedMeta = CourseHelper::sanitizeLessonMeta(Arr::get($lessonData, 'meta', []), $lesson);
1003 $updatedMeta['document_ids'] = Arr::get($lesson->meta, 'document_ids', []);
1004
1005 if ($mediaId = Arr::get($updatedMeta, 'featured_image_id')) { // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.FoundInControlStructure
1006 if (!$lesson->isQuizType()) {
1007 $media = wp_get_attachment_image_url($mediaId);
1008 $lesson->featured_image = $media ? $media : null;
1009 } else {
1010 $media = Helper::getMediaFromUrl(sanitize_url($mediaId));
1011 if ($media && !$media->is_active) {
1012 Helper::removeMediaByUrl($lesson->featured_image, $lesson->id);
1013 $lesson->featured_image = $media->public_url;
1014 $media->update([
1015 'is_active' => true,
1016 'user_id' => get_current_user_id(),
1017 'sub_object_id' => $lesson->id,
1018 'object_source' => 'quiz_thumbnail_' . $lesson->id,
1019 ]);
1020 }
1021 }
1022 } else {
1023 Helper::removeMediaByUrl($lesson->featured_image, $lesson->id);
1024 $lesson->featured_image = null;
1025 }
1026
1027 $updateData = array_filter([
1028 'title' => sanitize_text_field(Arr::get($lessonData, 'title')),
1029 'status' => Arr::get($lessonData, 'status'),
1030 'meta' => wp_parse_args($updatedMeta, $lesson->meta),
1031 ]);
1032
1033 // message bypasses array_filter so an emptied lesson body still saves
1034 if (array_key_exists('message', $lessonData)) {
1035 $updateData['message'] = CourseHelper::santizeLessonBody((string) Arr::get($lessonData, 'message'));
1036 }
1037
1038 $updateData = apply_filters('fluent_community/lesson/update_data', $updateData, $lesson);
1039
1040 $lesson->fill($updateData);
1041 $dirtyFields = $lesson->getDirty();
1042
1043 if ($dirtyFields) {
1044 $lesson->save();
1045 $isNewlyPublished = $lesson->status === 'published' && $previousStatus !== 'published';
1046 do_action('fluent_community/lesson/updated', $lesson, $dirtyFields, $isNewlyPublished);
1047 }
1048
1049 do_action('fluent_community/lesson/additional_media_updated', $request->all(), $lesson, $updateData);
1050
1051 return [
1052 'message' => __('Lesson has been updated successfully.', 'fluent-community'),
1053 'lesson' => $lesson,
1054 ];
1055 }
1056
1057 public function patchLesson(Request $request, $courseId, $lessionId)
1058 {
1059 $lesson = CourseLesson::whereHas('course', function ($query) use ($courseId) {
1060 $query->where('id', $courseId);
1061 })
1062 ->where('id', $lessionId)
1063 ->firstOrFail();
1064
1065 $acceptedFields = [ 'title', 'status', 'slug' ];
1066
1067 // empty title/slug/status must not overwrite, but a literal "0" is a valid value
1068 // Request::only() is a plain array pick with no sanitization, so each field is
1069 // sanitized here the same way createLesson/updateLesson do it. The title is
1070 // rendered with v-html in the course views, so it must not carry markup.
1071 $lessonData = array_filter($request->only($acceptedFields), function ($value) {
1072 return $value !== null && $value !== '';
1073 });
1074
1075 if (isset($lessonData['title'])) {
1076 $lessonData['title'] = sanitize_text_field($lessonData['title']);
1077 }
1078
1079 if (isset($lessonData['slug'])) {
1080 $lessonData['slug'] = sanitize_title($lessonData['slug']);
1081 }
1082
1083 if (isset($lessonData['status']) && !in_array($lessonData['status'], ['draft', 'published', 'archived'], true)) {
1084 unset($lessonData['status']);
1085 }
1086
1087 if (Arr::get($lessonData, 'status') === 'published' && $lesson->status !== 'published') {
1088 if (empty($lesson->scheduled_at)) {
1089 $lessonData['scheduled_at'] = current_time('mysql');
1090 }
1091 }
1092
1093 // message bypasses the empty-value filter above so an emptied lesson body still saves
1094 if ($request->exists('message')) {
1095 $lessonData['message'] = CourseHelper::santizeLessonBody((string) $request->get('message'));
1096 }
1097
1098 if (!empty($lessonData)) {
1099 $lesson->fill($lessonData);
1100 if ($lesson->isDirty()) {
1101 $lesson->save();
1102 }
1103 }
1104
1105 return [
1106 'message' => __('Lesson has been updated successfully.', 'fluent-community'),
1107 'lesson' => $lesson,
1108 ];
1109 }
1110
1111 public function deleteLesson(Request $request, $courseId, $lessionId)
1112 {
1113 $lesson = CourseLesson::whereHas('course', function ($query) use ($courseId) {
1114 $query->where('id', $courseId);
1115 })
1116 ->where('id', $lessionId)
1117 ->firstOrFail();
1118
1119 do_action('fluent_community/lesson/before_deleted', $lesson);
1120
1121 $lesson->delete();
1122
1123 return [
1124 'message' => __('Lesson has been deleted successfully.', 'fluent-community'),
1125 ];
1126 }
1127
1128 public function duplicateLesson(Request $request, $courseId, $lessonId)
1129 {
1130 Course::findOrFail($courseId);
1131
1132 $lesson = CourseLesson::where('id', $lessonId)
1133 ->where('space_id', $courseId)
1134 ->firstOrFail();
1135
1136 $siblings = CourseLesson::where('parent_id', $lesson->parent_id)
1137 ->where('space_id', $courseId)
1138 ->orderBy('priority', 'ASC')
1139 ->orderBy('id', 'ASC')
1140 ->get();
1141
1142 $sourceIndex = $siblings->search(function ($sibling) use ($lesson) {
1143 return (int)$sibling->id === (int)$lesson->id;
1144 });
1145
1146 if ($sourceIndex === false) {
1147 $sourceIndex = $siblings->count() - 1;
1148 }
1149
1150 $existingTitles = $siblings->pluck('title')->toArray();
1151 $duplicateTitle = $lesson->title . ' (Copy)';
1152 if (in_array($duplicateTitle, $existingTitles, true)) {
1153 $counter = 2;
1154 while (in_array($lesson->title . ' (Copy ' . $counter . ')', $existingTitles, true)) {
1155 ++$counter;
1156 }
1157 $duplicateTitle = $lesson->title . ' (Copy ' . $counter . ')';
1158 }
1159
1160 /** @var CourseLesson $newLesson */
1161 $newLesson = $lesson->replicate();
1162 $newLesson->title = $duplicateTitle;
1163 $newLesson->slug = null;
1164 $newLesson->priority = $sourceIndex + 1;
1165 $newLesson->save();
1166
1167 $orderedIds = $siblings->pluck('id')->toArray();
1168 array_splice($orderedIds, $sourceIndex + 1, 0, [ $newLesson->id ]);
1169
1170 foreach ($orderedIds as $index => $siblingId) {
1171 CourseLesson::where('id', $siblingId)->update([ 'priority' => $index ]);
1172 }
1173
1174 $newLesson = CourseLesson::findOrFail($newLesson->id);
1175
1176 CourseHelper::copyLessonDocuments($lesson, $newLesson);
1177
1178 do_action('fluent_community/lesson/duplicated', $newLesson, $lesson);
1179
1180 return [
1181 'message' => __('Lesson has been duplicated successfully.', 'fluent-community'),
1182 'lesson' => $newLesson,
1183 ];
1184 }
1185
1186 public function getOtherUsers(Request $request, $courseId)
1187 {
1188 $selects = [
1189 'ID',
1190 'display_name',
1191 ];
1192
1193 if (current_user_can('list_users')) {
1194 $selects[] = 'user_email';
1195 }
1196
1197 $userQuery = User::select([ 'ID' ])
1198 ->whereDoesntHave('space_pivot', function ($q) use ($courseId) {
1199 $q->where('space_id', $courseId);
1200 })
1201 ->limit(100)
1202 ->searchBy($request->getSafe('search'));
1203
1204 if (is_multisite()) {
1205 global $wpdb;
1206 $blogId = get_current_blog_id();
1207 $blogPrefix = $wpdb->get_blog_prefix($blogId);
1208 $userQuery->whereHas('usermeta', function ($q) use ($blogPrefix) {
1209 $q->where('meta_key', $blogPrefix . 'capabilities');
1210 });
1211 }
1212
1213 $userIds = $userQuery->get()
1214 ->pluck('ID')
1215 ->toArray();
1216
1217 $users = User::select($selects)
1218 ->whereIn('ID', $userIds)
1219 ->paginate(100);
1220
1221 $data = [
1222 'users' => $users,
1223 ];
1224
1225 return apply_filters('fluent_community/admin_course_non_members_api_response', $data, $request->all());
1226 }
1227
1228 public function updateLinks(Request $request, $id)
1229 {
1230 $course = Course::findOrFail($id);
1231 $links = $request->get('links', []);
1232
1233 $links = array_map(function ($link) {
1234 return CustomSanitizer::santizeLinkItem($link);
1235 }, $links);
1236
1237 $settings = $course->settings;
1238 $settings['links'] = $links;
1239 $course->settings = $settings;
1240 $course->save();
1241
1242 return [
1243 'message' => __('Links have been updated for the course', 'fluent-community'),
1244 'links' => $links,
1245 ];
1246 }
1247
1248 public function getMetaSettings(Request $request, $id)
1249 {
1250 $course = Course::findOrFail($id);
1251 $metaSettings = apply_filters('fluent_community/course/meta_fields', [], $course, $request->all());
1252
1253 if (!$metaSettings) {
1254 return [
1255 'meta_settings' => null,
1256 ];
1257 }
1258
1259 return [
1260 'meta_settings' => $metaSettings,
1261 ];
1262 }
1263
1264 public function getOtherInstructors(Request $request, $courseId)
1265 {
1266 $search = $request->getSafe('search');
1267
1268 Course::findOrFail($courseId);
1269
1270 $selects = [
1271 'ID',
1272 'display_name',
1273 ];
1274
1275 if (current_user_can('list_users')) {
1276 $selects[] = 'user_email';
1277 }
1278
1279 $instructors = User::select($selects)
1280 ->limit(100)
1281 ->searchBy($search)
1282 ->get();
1283
1284 $data = [
1285 'instructors' => $instructors,
1286 ];
1287
1288 return apply_filters('fluent_community/admin_course_other_instructors_api_response', $data, $request->all());
1289 }
1290 }
1291