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

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