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

1,316 lines 46.3 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 /**
956 * Expects `title` and `section_id` at the top level of the request.
957 */
958 public function createLesson(Request $request, $courseId)
959 {
960 $this->validate($request->all(), [
961 'title' => 'required',
962 'section_id' => 'required',
963 ]);
964
965 $sectionId = (int)$request->get('section_id');
966
967 $topic = CourseTopic::whereHas('course', function ($query) use ($courseId) {
968 $query->where('id', $courseId);
969 })
970 ->where('id', $sectionId)
971 ->firstOrFail();
972
973 $lessonData = [
974 'title' => $request->getSafe('title'),
975 'parent_id' => $topic->id,
976 'space_id' => $courseId,
977 'status' => 'draft',
978 ];
979
980 $latestPriority = CourseLesson::where('type', 'course_lesson')
981 ->where('parent_id', $sectionId)
982 ->where('space_id', $courseId)
983 ->max('priority');
984
985 $lessonData['priority'] = (int) $latestPriority + 1;
986
987 $lessonData = apply_filters('fluent_community/lesson/create_data', $lessonData, $request);
988
989 $lesson = CourseLesson::create($lessonData);
990
991 $lesson = CourseLesson::findOrFail($lesson->id);
992
993 return [
994 'message' => __('Lesson has been created successfully.', 'fluent-community'),
995 'lesson' => $lesson,
996 ];
997 }
998
999 /**
1000 * Expects the fields nested under `lesson`, referencing the section as `parent_id`.
1001 */
1002 public function updateLesson(Request $request, $courseId, $lessionId)
1003 {
1004 Course::findOrFail($courseId);
1005
1006 $lessonData = (array)$request->get('lesson');
1007
1008 $this->validate($lessonData, [
1009 'title' => 'required',
1010 'parent_id' => 'required',
1011 'status' => 'required|in:draft,published,archived',
1012 ]);
1013
1014 CourseTopic::whereHas('course', function ($query) use ($courseId) {
1015 $query->where('id', $courseId);
1016 })
1017 ->where('id', $lessonData['parent_id'])
1018 ->firstOrFail();
1019
1020 /** @var CourseLesson $lesson */
1021 $lesson = CourseLesson::where('id', $lessionId)
1022 ->where('space_id', $courseId)
1023 ->firstOrFail();
1024
1025 $previousStatus = $lesson->status;
1026
1027 $updatedMeta = CourseHelper::sanitizeLessonMeta(Arr::get($lessonData, 'meta', []), $lesson);
1028 $updatedMeta['document_ids'] = Arr::get($lesson->meta, 'document_ids', []);
1029
1030 if ($mediaId = Arr::get($updatedMeta, 'featured_image_id')) { // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.FoundInControlStructure
1031 if (!$lesson->isQuizType()) {
1032 $media = wp_get_attachment_image_url($mediaId);
1033 $lesson->featured_image = $media ? $media : null;
1034 } else {
1035 $media = Helper::getMediaFromUrl(sanitize_url($mediaId));
1036 if ($media && !$media->is_active) {
1037 Helper::removeMediaByUrl($lesson->featured_image, $lesson->id);
1038 $lesson->featured_image = $media->public_url;
1039 $media->update([
1040 'is_active' => true,
1041 'user_id' => get_current_user_id(),
1042 'sub_object_id' => $lesson->id,
1043 'object_source' => 'quiz_thumbnail_' . $lesson->id,
1044 ]);
1045 }
1046 }
1047 } else {
1048 Helper::removeMediaByUrl($lesson->featured_image, $lesson->id);
1049 $lesson->featured_image = null;
1050 }
1051
1052 $updateData = array_filter([
1053 'title' => sanitize_text_field(Arr::get($lessonData, 'title')),
1054 'status' => Arr::get($lessonData, 'status'),
1055 'meta' => wp_parse_args($updatedMeta, $lesson->meta),
1056 ]);
1057
1058 // message bypasses array_filter so an emptied lesson body still saves
1059 if (array_key_exists('message', $lessonData)) {
1060 $updateData['message'] = CourseHelper::santizeLessonBody((string) Arr::get($lessonData, 'message'));
1061 }
1062
1063 $updateData = apply_filters('fluent_community/lesson/update_data', $updateData, $lesson);
1064
1065 $lesson->fill($updateData);
1066 $dirtyFields = $lesson->getDirty();
1067
1068 if ($dirtyFields) {
1069 $lesson->save();
1070 $isNewlyPublished = $lesson->status === 'published' && $previousStatus !== 'published';
1071 do_action('fluent_community/lesson/updated', $lesson, $dirtyFields, $isNewlyPublished);
1072 }
1073
1074 do_action('fluent_community/lesson/additional_media_updated', $request->all(), $lesson, $updateData);
1075
1076 return [
1077 'message' => __('Lesson has been updated successfully.', 'fluent-community'),
1078 'lesson' => $lesson,
1079 ];
1080 }
1081
1082 public function patchLesson(Request $request, $courseId, $lessionId)
1083 {
1084 $lesson = CourseLesson::whereHas('course', function ($query) use ($courseId) {
1085 $query->where('id', $courseId);
1086 })
1087 ->where('id', $lessionId)
1088 ->firstOrFail();
1089
1090 $acceptedFields = [ 'title', 'status', 'slug' ];
1091
1092 // empty title/slug/status must not overwrite, but a literal "0" is a valid value
1093 // Request::only() is a plain array pick with no sanitization, so each field is
1094 // sanitized here the same way createLesson/updateLesson do it. The title is
1095 // rendered with v-html in the course views, so it must not carry markup.
1096 $lessonData = array_filter($request->only($acceptedFields), function ($value) {
1097 return $value !== null && $value !== '';
1098 });
1099
1100 if (isset($lessonData['title'])) {
1101 $lessonData['title'] = sanitize_text_field($lessonData['title']);
1102 }
1103
1104 if (isset($lessonData['slug'])) {
1105 $lessonData['slug'] = sanitize_title($lessonData['slug']);
1106 }
1107
1108 if (isset($lessonData['status']) && !in_array($lessonData['status'], ['draft', 'published', 'archived'], true)) {
1109 unset($lessonData['status']);
1110 }
1111
1112 if (Arr::get($lessonData, 'status') === 'published' && $lesson->status !== 'published') {
1113 if (empty($lesson->scheduled_at)) {
1114 $lessonData['scheduled_at'] = current_time('mysql');
1115 }
1116 }
1117
1118 // message bypasses the empty-value filter above so an emptied lesson body still saves
1119 if ($request->exists('message')) {
1120 $lessonData['message'] = CourseHelper::santizeLessonBody((string) $request->get('message'));
1121 }
1122
1123 if (!empty($lessonData)) {
1124 $lesson->fill($lessonData);
1125 if ($lesson->isDirty()) {
1126 $lesson->save();
1127 }
1128 }
1129
1130 return [
1131 'message' => __('Lesson has been updated successfully.', 'fluent-community'),
1132 'lesson' => $lesson,
1133 ];
1134 }
1135
1136 public function deleteLesson(Request $request, $courseId, $lessionId)
1137 {
1138 $lesson = CourseLesson::whereHas('course', function ($query) use ($courseId) {
1139 $query->where('id', $courseId);
1140 })
1141 ->where('id', $lessionId)
1142 ->firstOrFail();
1143
1144 do_action('fluent_community/lesson/before_deleted', $lesson);
1145
1146 $lesson->delete();
1147
1148 return [
1149 'message' => __('Lesson has been deleted successfully.', 'fluent-community'),
1150 ];
1151 }
1152
1153 public function duplicateLesson(Request $request, $courseId, $lessonId)
1154 {
1155 Course::findOrFail($courseId);
1156
1157 $lesson = CourseLesson::where('id', $lessonId)
1158 ->where('space_id', $courseId)
1159 ->firstOrFail();
1160
1161 $siblings = CourseLesson::where('parent_id', $lesson->parent_id)
1162 ->where('space_id', $courseId)
1163 ->orderBy('priority', 'ASC')
1164 ->orderBy('id', 'ASC')
1165 ->get();
1166
1167 $sourceIndex = $siblings->search(function ($sibling) use ($lesson) {
1168 return (int)$sibling->id === (int)$lesson->id;
1169 });
1170
1171 if ($sourceIndex === false) {
1172 $sourceIndex = $siblings->count() - 1;
1173 }
1174
1175 $existingTitles = $siblings->pluck('title')->toArray();
1176 $duplicateTitle = $lesson->title . ' (Copy)';
1177 if (in_array($duplicateTitle, $existingTitles, true)) {
1178 $counter = 2;
1179 while (in_array($lesson->title . ' (Copy ' . $counter . ')', $existingTitles, true)) {
1180 ++$counter;
1181 }
1182 $duplicateTitle = $lesson->title . ' (Copy ' . $counter . ')';
1183 }
1184
1185 /** @var CourseLesson $newLesson */
1186 $newLesson = $lesson->replicate();
1187 $newLesson->title = $duplicateTitle;
1188 $newLesson->slug = null;
1189 $newLesson->priority = $sourceIndex + 1;
1190 $newLesson->save();
1191
1192 $orderedIds = $siblings->pluck('id')->toArray();
1193 array_splice($orderedIds, $sourceIndex + 1, 0, [ $newLesson->id ]);
1194
1195 foreach ($orderedIds as $index => $siblingId) {
1196 CourseLesson::where('id', $siblingId)->update([ 'priority' => $index ]);
1197 }
1198
1199 $newLesson = CourseLesson::findOrFail($newLesson->id);
1200
1201 CourseHelper::copyLessonDocuments($lesson, $newLesson);
1202
1203 do_action('fluent_community/lesson/duplicated', $newLesson, $lesson);
1204
1205 return [
1206 'message' => __('Lesson has been duplicated successfully.', 'fluent-community'),
1207 'lesson' => $newLesson,
1208 ];
1209 }
1210
1211 public function getOtherUsers(Request $request, $courseId)
1212 {
1213 $selects = [
1214 'ID',
1215 'display_name',
1216 ];
1217
1218 if (current_user_can('list_users')) {
1219 $selects[] = 'user_email';
1220 }
1221
1222 $userQuery = User::select([ 'ID' ])
1223 ->whereDoesntHave('space_pivot', function ($q) use ($courseId) {
1224 $q->where('space_id', $courseId);
1225 })
1226 ->limit(100)
1227 ->searchBy($request->getSafe('search'));
1228
1229 if (is_multisite()) {
1230 global $wpdb;
1231 $blogId = get_current_blog_id();
1232 $blogPrefix = $wpdb->get_blog_prefix($blogId);
1233 $userQuery->whereHas('usermeta', function ($q) use ($blogPrefix) {
1234 $q->where('meta_key', $blogPrefix . 'capabilities');
1235 });
1236 }
1237
1238 $userIds = $userQuery->get()
1239 ->pluck('ID')
1240 ->toArray();
1241
1242 $users = User::select($selects)
1243 ->whereIn('ID', $userIds)
1244 ->paginate(100);
1245
1246 $data = [
1247 'users' => $users,
1248 ];
1249
1250 return apply_filters('fluent_community/admin_course_non_members_api_response', $data, $request->all());
1251 }
1252
1253 public function updateLinks(Request $request, $id)
1254 {
1255 $course = Course::findOrFail($id);
1256 $links = $request->get('links', []);
1257
1258 $links = array_map(function ($link) {
1259 return CustomSanitizer::santizeLinkItem($link);
1260 }, $links);
1261
1262 $settings = $course->settings;
1263 $settings['links'] = $links;
1264 $course->settings = $settings;
1265 $course->save();
1266
1267 return [
1268 'message' => __('Links have been updated for the course', 'fluent-community'),
1269 'links' => $links,
1270 ];
1271 }
1272
1273 public function getMetaSettings(Request $request, $id)
1274 {
1275 $course = Course::findOrFail($id);
1276 $metaSettings = apply_filters('fluent_community/course/meta_fields', [], $course, $request->all());
1277
1278 if (!$metaSettings) {
1279 return [
1280 'meta_settings' => null,
1281 ];
1282 }
1283
1284 return [
1285 'meta_settings' => $metaSettings,
1286 ];
1287 }
1288
1289 public function getOtherInstructors(Request $request, $courseId)
1290 {
1291 $search = $request->getSafe('search');
1292
1293 Course::findOrFail($courseId);
1294
1295 $selects = [
1296 'ID',
1297 'display_name',
1298 ];
1299
1300 if (current_user_can('list_users')) {
1301 $selects[] = 'user_email';
1302 }
1303
1304 $instructors = User::select($selects)
1305 ->limit(100)
1306 ->searchBy($search)
1307 ->get();
1308
1309 $data = [
1310 'instructors' => $instructors,
1311 ];
1312
1313 return apply_filters('fluent_community/admin_course_other_instructors_api_response', $data, $request->all());
1314 }
1315 }
1316