PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.8.1
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.8.1
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.8.1, at Modules/Course/Http/Controllers/CourseAdminController.php

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