PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.01
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.01
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
← All changes | Modules/Course/Http/Controllers/CourseAdminController.php +746 -165 1.0.982.10.01 View file →
@@ -1,12 +1,13 @@
1 1 <?php
2 2
3 3 namespace FluentCommunity\Modules\Course\Http\Controllers;
4 4
5 +use FluentCommunity\App\App;
6 +use FluentCommunity\App\Functions\Utility;
5 7 use FluentCommunity\App\Http\Controllers\Controller;
6 8 use FluentCommunity\App\Models\BaseSpace;
7 9 use FluentCommunity\App\Models\Comment;
8 -use FluentCommunity\App\Models\Feed;
9 10 use FluentCommunity\App\Models\Reaction;
10 11 use FluentCommunity\App\Models\User;
11 12 use FluentCommunity\App\Models\XProfile;
12 13 use FluentCommunity\App\Services\CustomSanitizer;
@@ -25,13 +26,30 @@
25 26 class CourseAdminController extends Controller
26 27 {
27 28 public function getCourses(Request $request)
28 29 {
29 - $courses = Course::searchBy($request->getSafe('search'))
30 - ->orderBy('id', 'DESC')
31 - ->with(['owner'])
32 - ->paginate();
30 + $user = $this->getUser();
33 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 +
34 52 foreach ($courses as $course) {
35 53 $course->students_count = $course->students()->count();
36 54 if (!$course->cover_photo) {
37 55 $course->cover_photo = FLUENT_COMMUNITY_PLUGIN_URL . 'assets/images/course-placeholder.jpg';
@@ -39,11 +57,14 @@
39 57 $course->sectionsCount = CourseTopic::where('space_id', $course->id)->count();
40 58 $course->lessonsCount = CourseLesson::where('space_id', $course->id)->count();
41 59 }
42 60
43 - return [
44 - 'courses' => $courses
61 + $data = [
62 + 'courses' => $courses,
63 + 'course_categories' => $request->get('with_categories') ? CourseHelper::getCourseCategories() : [],
45 64 ];
65 +
66 + return apply_filters('fluent_community/admin_courses_api_response', $data, $request->all());
46 67 }
47 68
48 69 public function createCourse(Request $request)
49 70 {
@@ -51,35 +72,69 @@
51 72 'title' => 'required',
52 73 'description' => 'required',
53 74 'privacy' => 'required|in:public,private,secret',
54 75 'course_type' => 'required|in:self_paced,structured,scheduled',
55 - 'parent_id' => 'nullable|exists:fcom_spaces,id'
56 76 ]);
57 77
58 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 + }
59 84
60 85 $courseData = [
61 - 'parent_id' => $request->get('parent_id'),
86 + 'parent_id' => $request->get('parent_id') ?: null, // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
62 87 'title' => $request->getSafe('title', 'sanitize_text_field'),
63 88 'privacy' => $request->get('privacy'),
64 - 'description' => wp_kses_post($request->get('description')),
65 - 'status' => 'draft',
89 + 'description' => wp_kses_post((string) $request->get('description', '')),
90 + 'status' => $request->get('status', 'draft'),
66 91 'settings' => [
67 - 'course_type' => $request->get('course_type'),
68 - 'emoji' => CustomSanitizer::sanitizeEmoji($request->get('settings.emoji', '')),
69 - 'shape_svg' => CustomSanitizer::sanitizeSvg($request->get('settings.shape_svg', '')),
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',
70 102 ],
71 - 'serial' => BaseSpace::where('parent_id', $parentId)->max('serial') + 1
103 + 'serial' => $serial,
72 104 ];
73 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 +
74 127 $slug = $request->get('slug');
75 128
76 - if (!$slug) {
77 - $slug = sanitize_title($courseData['title']);
78 - }
129 + $slug = $slug ? $slug : $courseData['title'];
79 130
131 + $slug = preg_replace('/[^a-zA-Z0-9-_]/', '', $slug);
132 +
133 + $slug = sanitize_title($slug, '');
134 +
80 135 if ($slug) {
81 - $slug = sanitize_title($slug);
136 + $slug = Utility::slugify($slug);
82 137 $exist = Course::where('slug', $slug)
83 138 ->exists();
84 139
85 140 if ($exist) {
@@ -92,9 +147,9 @@
92 147 do_action('fluent_community/course/before_create', $courseData);
93 148
94 149 $course = Course::create($courseData);
95 150
96 - $imageTypes = ['cover_photo', 'logo'];
151 + $imageTypes = [ 'cover_photo', 'logo' ];
97 152
98 153 $metaData = [];
99 154 foreach ($imageTypes as $type) {
100 155 if (!empty($request->get($type))) {
@@ -106,9 +161,9 @@
106 161 $media->update([
107 162 'is_active' => true,
108 163 'user_id' => get_current_user_id(),
109 164 'sub_object_id' => $course->id,
110 - 'object_source' => 'space_' . $type
165 + 'object_source' => 'space_' . $type,
111 166 ]);
112 167 }
113 168 }
114 169
@@ -116,19 +171,28 @@
116 171 $course->fill($metaData);
117 172 $course->save();
118 173 }
119 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 +
120 182 do_action('fluent_community/course/created', $course);
121 183
122 184 return [
123 - 'course' => $course
185 + 'message' => __('Course has been created successfully', 'fluent-community'),
186 + 'course' => $course,
124 187 ];
125 188 }
126 189
127 190 public function findCourse(Request $request, $courseId)
128 191 {
192 + /** @var Course $course */
129 193 $course = Course::where('id', $courseId)
130 - ->with(['owner'])
194 + ->with([ 'owner' ])
131 195 ->firstOrFail();
132 196
133 197 $course->students_count = $course->students()->count();
134 198 $course->course_type = $course->settings['course_type'];
@@ -142,10 +206,12 @@
142 206 }
143 207
144 208 unset($course->categories);
145 209
210 + $course = apply_filters('fluent_community/course_info', $course, $request->all());
211 +
146 212 return [
147 - 'course' => $course
213 + 'course' => $course,
148 214 ];
149 215 }
150 216
151 217 public function updateCourse(Request $request, $courseId)
@@ -155,9 +221,9 @@
155 221 'description' => 'required',
156 222 'privacy' => 'required|in:public,private,secret',
157 223 'status' => 'required|in:draft,published,archived',
158 224 'course_type' => 'required|in:self_paced,structured,scheduled',
159 - 'parent_id' => 'required|exists:fcom_spaces,id'
225 + 'created_by' => 'exists:users,ID',
160 226 ]);
161 227
162 228 $course = Course::findOrFail($courseId);
163 229
@@ -163,16 +229,37 @@
163 229
164 230 $courseData = [
165 231 'title' => $request->getSafe('title', 'sanitize_text_field'),
166 232 'privacy' => $request->get('privacy'),
167 - 'description' => wp_kses_post($request->get('description')),
233 + 'description' => wp_kses_post((string) $request->get('description', '')),
168 234 'status' => $request->get('status'),
169 235 'cover_photo' => $request->getSafe('cover_photo', 'sanitize_url'),
170 - 'parent_id' => $request->get('parent_id'),
236 + 'parent_id' => $request->get('parent_id') ?: null, // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
171 237 ];
172 238
173 - $imageTypes = ['cover_photo', 'logo'];
239 + $slug = $request->get('slug');
240 + if ($slug && $course->slug != $slug) {
241 + $slug = Utility::slugify($slug);
174 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 +
175 262 foreach ($imageTypes as $type) {
176 263 if (!empty($request->get($type))) {
177 264 $media = Helper::getMediaFromUrl($request->get($type));
178 265 if (!$media || $media->is_active) {
@@ -182,23 +269,56 @@
182 269 $media->update([
183 270 'is_active' => true,
184 271 'user_id' => get_current_user_id(),
185 272 'sub_object_id' => $course->id,
186 - 'object_source' => 'space_' . $type
273 + 'object_source' => 'space_' . $type,
187 274 ]);
275 + } else {
276 + $courseData[$type] = null;
188 277 }
189 278 }
190 279
191 280 $existingSettings = $course->settings;
192 281 $existingSettings['course_type'] = $request->get('course_type');
193 - $existingSettings['custom_lock_screen'] = $request->get('settings.custom_lock_screen') === 'yes' ? 'yes' : 'no';
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;
194 298 $existingSettings['emoji'] = CustomSanitizer::sanitizeEmoji($request->get('settings.emoji', ''));
195 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';
196 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 +
197 316 $courseData['settings'] = $existingSettings;
198 317
318 + $previousStatus = $course->status;
199 319
200 - $previousStatus = $course->status;
320 + $prevCourse = clone $course;
201 321
202 322 $course->fill($courseData);
203 323 $dirtyFields = $course->getDirty();
204 324
@@ -203,24 +323,90 @@
203 323 $dirtyFields = $course->getDirty();
204 324
205 325 if ($dirtyFields) {
206 326 $course->save();
207 - do_action('fluent_community/course/updated', $course, $dirtyFields);
208 -
209 - if($previousStatus != 'published' && $course->status == 'published') {
327 + do_action('fluent_community/course/updated', $course, $dirtyFields, $prevCourse);
328 + if ($previousStatus != 'published' && $course->status == 'published') {
210 329 do_action('fluent_community/course/published', $course);
211 330 }
331 + }
212 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);
213 338 }
214 339
215 - $course->syncCategories($request->get('category_ids', []));
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 + }
216 346
217 347 return [
218 348 'message' => __('Course has been updated successfully.', 'fluent-community'),
219 - 'course' => $course
349 + 'course' => $course,
220 350 ];
221 351 }
222 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 +
223 409 public function deleteCourse(Request $request, $courseId)
224 410 {
225 411 $course = Course::findOrFail($courseId);
226 412
@@ -234,10 +420,23 @@
234 420 Comment::whereHas('post', function ($q) use ($course) {
235 421 $q->where('space_id', $course->id);
236 422 })->delete();
237 423
238 - Feed::withoutGlobalScopes()->where('space_id', $course->id)->delete();
424 + $courseTopics = CourseTopic::with('lessons')
425 + ->where('space_id', $course->id)
426 + ->get();
239 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 +
240 439 // Let's delete the student enrollments
241 440 SpaceUserPivot::where('space_id', $course->id)
242 441 ->delete();
243 442
@@ -246,33 +445,34 @@
246 445
247 446 do_action('fluent_community/course/deleted', $courseId);
248 447
249 448 return [
250 - 'message' => __('Course has been deleted successfully along with all the associated data', 'fluent-community')
449 + 'message' => __('Course has been deleted successfully along with all the associated data', 'fluent-community'),
251 450 ];
252 451 }
253 452
254 453 public function getCourseComments(Request $request, $courseId)
255 454 {
256 - Course::findOrFail($courseId);
455 + $course = Course::findOrFail($courseId);
257 456
258 - $comments = Comment::whereHas('post', function ($q) use ($courseId) {
259 - return $q->where('space_id', $courseId);
260 - })
457 + $comments = Comment::byContentModerationAccessStatus($this->getUser(), $course)
458 + ->whereHas('post', function ($q) use ($courseId) {
459 + return $q->where('space_id', $courseId);
460 + })
261 461 ->orderBy('id', 'DESC')
262 462 ->with([
263 463 'post' => function ($q) {
264 - return $q->select(['id', 'title', 'slug']);
464 + return $q->select([ 'id', 'title', 'slug' ]);
265 465 },
266 466 'xprofile' => function ($q) {
267 467 $q->select(ProfileHelper::getXProfilePublicFields());
268 - }
468 + },
269 469 ])
270 470 ->paginate();
271 471
272 472 foreach ($comments as $comment) {
273 473 if ($comment->user) {
274 - $comment->user->makeHidden(['user_email']);
474 + $comment->user->makeHidden([ 'user_email' ]);
275 475 }
276 476 $likedIds = FeedsHelper::getLikedIdsByUserFeedId($comment->post_id, get_current_user_id());
277 477 if ($likedIds && in_array($comment->id, $likedIds)) {
278 478 $comment->liked = 1;
@@ -278,11 +478,13 @@
278 478 $comment->liked = 1;
279 479 }
280 480 }
281 481
282 - return [
283 - 'comments' => $comments
482 + $data = [
483 + 'comments' => $comments,
284 484 ];
485 +
486 + return apply_filters('fluent_community/admin_course_comments_api_response', $data, $request->all());
285 487 }
286 488
287 489 public function getCourseStudents(Request $request, $courseId)
288 490 {
@@ -287,30 +489,57 @@
287 489 public function getCourseStudents(Request $request, $courseId)
288 490 {
289 491 Course::findOrFail($courseId);
290 492
291 - $search = $request->getSafe('search', 'sanitize_text_field');
493 + $search = $request->getSafe('search');
292 494
293 - $students = XProfile::whereHas('space_pivot', function ($q) use ($courseId) {
294 - return $q->where('space_id', $courseId)
295 - ->where('role', 'student');
296 - })
297 - ->searchBy($search)
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')
298 515 ->with([
299 516 'space_pivot' => function ($q) use ($courseId) {
300 517 return $q->where('space_id', $courseId);
301 518 },
302 519 ])
303 - ->select(ProfileHelper::getXProfilePublicFields())
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)
304 527 ->paginate();
305 528
529 + $studentUserIds = $students->pluck('user_id')->toArray();
530 +
531 + $progressMap = CourseHelper::getBulkCourseProgress($courseId, $studentUserIds);
532 +
306 533 foreach ($students as $student) {
307 - $student->progress = CourseHelper::getCourseProgress($courseId, $student->user_id);
534 + $student->progress = $progressMap[$student->user_id] ?? 0;
308 535 }
309 536
310 - return [
311 - 'students' => $students
537 + $data = [
538 + 'students' => $students,
312 539 ];
540 +
541 + return apply_filters('fluent_community/admin_course_students_api_response', $data, $request->all());
313 542 }
314 543
315 544 public function addStudent(Request $request, $courseId)
316 545 {
@@ -316,9 +545,9 @@
316 545 {
317 546 $course = Course::findOrFail($courseId);
318 547
319 548 $this->validate($request->all(), [
320 - 'user_id' => 'required|exists:users,ID'
549 + 'user_id' => 'required|exists:users,ID',
321 550 ]);
322 551
323 552 $userId = (int)$request->get('user_id');
324 553 $targetUser = User::findOrFail($userId);
@@ -325,24 +554,22 @@
325 554 $xprofile = $targetUser->syncXProfile();
326 555
327 556 if ($xprofile && $xprofile->status != 'active') {
328 557 return $this->sendError([
329 - 'message' => __('Selected user is not active', 'fluent-community')
558 + 'message' => __('Selected user is not active', 'fluent-community'),
330 559 ]);
331 560 }
332 561
333 - $admin = User::find(get_current_user_id());
334 - $admin->verifySpacePermission('can_add_member', $course);
335 562 $enrolled = CourseHelper::enrollCourse($course, $userId, 'by_admin');
336 563
337 564 if (!$enrolled) {
338 565 return $this->sendError([
339 - 'message' => __('User is already added to this course.', 'fluent-community')
566 + 'message' => __('User is already added to this course.', 'fluent-community'),
340 567 ]);
341 568 }
342 569
343 570 return [
344 - 'message' => __('User has been added to this course', 'fluent-community')
571 + 'message' => __('User has been added to this course', 'fluent-community'),
345 572 ];
346 573 }
347 574
348 575 public function removeStudent(Request $request, $courseId, $studentId)
@@ -348,12 +575,8 @@
348 575 public function removeStudent(Request $request, $courseId, $studentId)
349 576 {
350 577 $course = Course::findOrFail($courseId);
351 578
352 - $admin = User::find(get_current_user_id());
353 -
354 - $admin->verifySpacePermission('can_remove_member', $course);
355 -
356 579 $student = SpaceUserPivot::bySpace($course->id)
357 580 ->byUser($studentId)
358 581 ->first();
359 582
@@ -358,9 +581,9 @@
358 581 ->first();
359 582
360 583 if (!$student) {
361 584 return $this->sendError([
362 - 'message' => __('Selected user is not a student of this course', 'fluent-community')
585 + 'message' => __('Selected user is not a student of this course', 'fluent-community'),
363 586 ]);
364 587 }
365 588
366 589 Helper::removeFromSpace($course, $studentId, 'by_admin');
@@ -365,34 +588,60 @@
365 588
366 589 Helper::removeFromSpace($course, $studentId, 'by_admin');
367 590
368 591 return [
369 - 'message' => __('Student has been removed from this course', 'fluent-community')
592 + 'message' => __('Student has been removed from this course', 'fluent-community'),
370 593 ];
371 594 }
372 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 +
373 618 public function getSections(Request $request, $courseId)
374 619 {
375 620 $course = Course::findOrFail($courseId);
621 + /** @var Course $course */
376 622
377 623 $sectionsQuery = CourseTopic::where('space_id', $courseId)
378 - ->orderBy('priority', 'ASC');
624 + ->orderBy('priority', 'ASC')
625 + ->orderBy('id', 'ASC');
379 626
380 627 if (in_array('only_published', $request->get('conditions', []))) {
381 628 $sectionsQuery->where('status', 'published')
382 - ->with(['lessons' => function ($q) {
629 + ->with([
630 + 'lessons' => function ($q) {
383 631 $q->where('status', 'published');
384 - }]);
632 + },
633 + ]);
385 634 }
386 635
387 636 if (empty($request->get('conditions', []))) {
388 - $sectionsQuery->with(['lessons']);
637 + $sectionsQuery->with([ 'lessons' ]);
389 638 }
390 639
391 640 $sections = $sectionsQuery->get();
392 641
393 642 $data = [
394 - 'sections' => $sections
643 + 'sections' => $sections,
395 644 ];
396 645
397 646 if ($request->get('with_lock_screen')) {
398 647 $data['lockscreen'] = LockscreenService::getLockscreenSettings($course);
@@ -397,24 +646,26 @@
397 646 if ($request->get('with_lock_screen')) {
398 647 $data['lockscreen'] = LockscreenService::getLockscreenSettings($course);
399 648 }
400 649
401 - return $data;
650 + return apply_filters('fluent_community/admin_course_sections_api_response', $data, $request->all());
402 651 }
403 652
404 653 public function getSection(Request $request, $courseId, $topicId)
405 654 {
406 655 $topic = CourseTopic::where('space_id', $courseId)
407 - ->whereHas('course', function($query) use ($courseId) {
656 + ->whereHas('course', function ($query) use ($courseId) {
408 657 $query->where('id', $courseId);
409 658 })
410 659 ->where('id', $topicId)
411 - ->with(['lessons'])
660 + ->with([ 'lessons' ])
412 661 ->firstOrFail();
413 662
414 - return [
415 - 'topic' => $topic
663 + $data = [
664 + 'topic' => $topic,
416 665 ];
666 +
667 + return apply_filters('fluent_community/admin_course_section_api_response', $data, $request->all());
417 668 }
418 669
419 670 public function resetSectionIndexes(Request $request, $courseId)
420 671 {
@@ -424,9 +675,10 @@
424 675 ->whereIn('id', array_keys($indexes))
425 676 ->get();
426 677
427 678 foreach ($sections as $section) {
428 - if (!isset($indexes[$section->id])) continue;
679 + if (!isset($indexes[$section->id])) { continue;
680 + }
429 681 $section->priority = $indexes[$section->id];
430 682 $section->save();
431 683 }
432 684
@@ -431,9 +683,9 @@
431 683 }
432 684
433 685 return [
434 686 'sections' => $sections,
435 - 'message' => __('Section indexes have been updated successfully.', 'fluent-community')
687 + 'message' => __('Section indexes have been updated successfully.', 'fluent-community'),
436 688 ];
437 689 }
438 690
439 691 public function resetLessonIndexes(Request $request, $courseId, $sectionId)
@@ -445,9 +697,10 @@
445 697 ->whereIn('id', array_keys($indexes))
446 698 ->get();
447 699
448 700 foreach ($lessons as $lesson) {
449 - if (!isset($indexes[$lesson->id])) continue;
701 + if (!isset($indexes[$lesson->id])) { continue;
702 + }
450 703 $lesson->priority = $indexes[$lesson->id];
451 704 $lesson->save();
452 705 }
453 706
@@ -452,33 +705,57 @@
452 705 }
453 706
454 707 return [
455 708 'lessons' => $lessons,
456 - 'message' => __('Lesson indexes have been updated successfully.', 'fluent-community')
709 + 'message' => __('Lesson indexes have been updated successfully.', 'fluent-community'),
457 710 ];
458 711 }
459 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 +
460 731 public function createSection(Request $request, $courseId)
461 732 {
462 733 $this->validate($request->all(), [
463 - 'title' => 'required'
734 + 'title' => 'required',
464 735 ]);
465 736
466 737 $sectionData = [
467 738 'title' => $request->getSafe('title'),
468 739 'space_id' => $courseId,
469 - 'status' => 'published'
740 + 'status' => 'published',
470 741 ];
471 742
472 - Course::findOrFail($courseId);
743 + $course = Course::findOrFail($courseId);
473 744
745 + $latestPriority = CourseTopic::where('type', 'course_section')->where('space_id', $courseId)->max('priority');
746 +
747 + $sectionData['priority'] = (int) $latestPriority + 1;
748 +
474 749 $section = CourseTopic::create($sectionData);
475 750
476 751 $section->load('lessons');
477 752
753 + do_action('fluent_community/section/created', $section, $course);
754 +
478 755 return [
479 - 'message' => __('Topic has been created successfully.', 'fluent-community'),
480 - 'section' => $section
756 + 'message' => __('Section has been created successfully.', 'fluent-community'),
757 + 'section' => $section,
481 758 ];
482 759 }
483 760
484 761 public function updateSection(Request $request, $courseId, $tipicId)
@@ -484,12 +761,12 @@
484 761 public function updateSection(Request $request, $courseId, $tipicId)
485 762 {
486 763 $this->validate($request->all(), [
487 764 'title' => 'required',
488 - 'status' => 'required|in:draft,published,archived'
765 + 'status' => 'required|in:draft,published,archived',
489 766 ]);
490 767
491 - $course = Course::findOrFail($courseId);
768 + Course::findOrFail($courseId);
492 769
493 770 $topic = CourseTopic::where('space_id', $courseId)
494 771 ->where('id', $tipicId)
495 772 ->firstOrFail();
@@ -496,16 +773,16 @@
496 773
497 774
498 775 $topicData = [
499 776 'title' => $request->getSafe('title'),
500 - 'status' => $request->get('status')
777 + 'status' => $request->get('status'),
501 778 ];
502 779
503 - $course->update($topicData);
780 + $topic->update($topicData);
504 781
505 782 return [
506 783 'message' => __('Topic has been updated successfully.', 'fluent-community'),
507 - 'topic' => $topic
784 + 'topic' => $topic,
508 785 ];
509 786 }
510 787
511 788 public function patchSection(Request $request, $courseId, $tipicId)
@@ -515,52 +792,135 @@
515 792 $topic = CourseTopic::where('space_id', $courseId)
516 793 ->where('id', $tipicId)
517 794 ->firstOrFail();
518 795
519 - $acceptedFields = ['title', 'status'];
796 + $acceptedFields = [ 'title', 'status' ];
520 797
521 798 if ($course->getCourseType() == 'scheduled') {
522 799 $acceptedFields[] = 'scheduled_at';
523 - } else if ($course->getCourseType() == 'structured') {
800 + } elseif ($course->getCourseType() == 'structured') {
524 801 $acceptedFields[] = 'reactions_count';
525 802 }
526 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);
527 808
528 - $topicData = $request->only($acceptedFields);
809 + if (isset($topicData['title'])) {
810 + $topicData['title'] = sanitize_text_field($topicData['title']);
811 + }
529 812
813 + if (isset($topicData['status'])) {
814 + $topicData['status'] = sanitize_text_field($topicData['status']);
815 + }
816 +
530 817 if (!empty($topicData['scheduled_at'])) {
531 818 $topic->reactions_count = 0;
532 - } else if (isset($topicData['reactions_count'])) {
819 + } elseif (isset($topicData['reactions_count'])) {
533 820 $topic->scheduled_at = null;
534 821 $topic->reactions_count = $topicData['reactions_count'];
535 822 }
536 823
537 - $topicData = array_filter($topicData);
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 +
538 830 $topic->fill($topicData);
831 +
832 + $scheduledAtDirty = $topic->isDirty('scheduled_at');
833 + $reactionsCountDirty = $topic->isDirty('reactions_count');
834 +
539 835 $topic->save();
540 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 +
541 844 return [
542 845 'message' => __('Topic has been updated successfully.', 'fluent-community'),
543 - 'topic' => $topic
846 + 'topic' => $topic,
544 847 ];
545 848 }
546 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 +
547 900 public function deleteSection(Request $request, $courseId, $sectionId)
548 901 {
549 902 $topic = CourseTopic::where([
550 - 'id' => $sectionId,
551 - 'space_id' => $courseId
903 + 'id' => $sectionId,
904 + 'space_id' => $courseId,
552 905 ])->firstOrFail();
553 906
907 + do_action('fluent_community/section/before_deleted', $topic);
908 +
554 909 $topic->delete();
555 910
556 - CourseLesson::where([
911 + $lessons = CourseLesson::where([
557 912 'parent_id' => $sectionId,
558 - 'space_id' => $courseId
559 - ])->delete();
913 + 'space_id' => $courseId,
914 + ])->get();
560 915
916 + foreach ($lessons as $lesson) {
917 + do_action('fluent_community/lesson/before_deleted', $lesson);
918 + $lesson->delete();
919 + }
920 +
561 921 return [
562 - 'message' => __('Section has been deleted successfully.', 'fluent-community')
922 + 'message' => __('Section has been deleted successfully.', 'fluent-community'),
563 923 ];
564 924 }
565 925
566 926 public function getLessons(Request $request, $courseId)
@@ -567,9 +927,10 @@
567 927 {
568 928 Course::findOrFail($courseId);
569 929
570 930 $lessons = CourseLesson::where('space_id', $courseId)
571 - ->orderBy('priority', 'ASC');
931 + ->orderBy('priority', 'ASC')
932 + ->orderBy('id', 'ASC');
572 933
573 934 $topicId = (int)$request->get('topic_id');
574 935
575 936 if ($topicId) {
@@ -577,39 +938,46 @@
577 938 }
578 939
579 940 $lessons = $lessons->get();
580 941
581 - return [
582 - 'lessons' => $lessons
942 + $data = [
943 + 'lessons' => $lessons,
583 944 ];
945 +
946 + return apply_filters('fluent_community/admin_course_lessons_api_response', $data, $request->all());
584 947 }
585 948
586 949 public function getLesson(Request $request, $courseId, $lessonId)
587 950 {
588 - $lesson = CourseLesson::whereHas('course', function($query) use ($courseId) {
589 - $query->where('id', $courseId);
590 - })
951 + $lesson = CourseLesson::whereHas('course', function ($query) use ($courseId) {
952 + $query->where('id', $courseId);
953 + })
591 954 ->where('id', $lessonId)
592 - ->with(['topic', 'course'])
955 + ->with([ 'topic', 'course' ])
593 956 ->firstOrFail();
594 957
595 - return [
596 - 'lesson' => $lesson
958 + $data = [
959 + 'lesson' => $lesson,
597 960 ];
961 +
962 + return apply_filters('fluent_community/admin_course_lesson_api_response', $data, $request->all());
598 963 }
599 964
965 + /**
966 + * Expects `title` and `section_id` at the top level of the request.
967 + */
600 968 public function createLesson(Request $request, $courseId)
601 969 {
602 970 $this->validate($request->all(), [
603 971 'title' => 'required',
604 - 'section_id' => 'required'
972 + 'section_id' => 'required',
605 973 ]);
606 974
607 975 $sectionId = (int)$request->get('section_id');
608 976
609 - $topic = CourseTopic::whereHas('course', function($query) use ($courseId) {
610 - $query->where('id', $courseId);
611 - })
977 + $topic = CourseTopic::whereHas('course', function ($query) use ($courseId) {
978 + $query->where('id', $courseId);
979 + })
612 980 ->where('id', $sectionId)
613 981 ->firstOrFail();
614 982
615 983 $lessonData = [
@@ -615,37 +983,54 @@
615 983 $lessonData = [
616 984 'title' => $request->getSafe('title'),
617 985 'parent_id' => $topic->id,
618 986 'space_id' => $courseId,
619 - 'status' => 'draft'
987 + 'status' => 'draft',
620 988 ];
621 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 +
622 999 $lesson = CourseLesson::create($lessonData);
623 1000
624 1001 $lesson = CourseLesson::findOrFail($lesson->id);
625 1002
1003 + do_action('fluent_community/lesson/created', $lesson, $topic);
1004 +
626 1005 return [
627 1006 'message' => __('Lesson has been created successfully.', 'fluent-community'),
628 - 'lesson' => $lesson
1007 + 'lesson' => $lesson,
629 1008 ];
630 1009 }
631 1010
1011 + /**
1012 + * Expects the fields nested under `lesson`, referencing the section as `parent_id`.
1013 + */
632 1014 public function updateLesson(Request $request, $courseId, $lessionId)
633 1015 {
634 - $lessonData = $request->get('lesson');
1016 + Course::findOrFail($courseId);
635 1017
1018 + $lessonData = (array)$request->get('lesson');
1019 +
636 1020 $this->validate($lessonData, [
637 1021 'title' => 'required',
638 1022 'parent_id' => 'required',
639 - 'status' => 'required|in:draft,published,archived'
1023 + 'status' => 'required|in:draft,published,archived',
640 1024 ]);
641 1025
642 - CourseTopic::whereHas('course', function($query) use ($courseId) {
643 - $query->where('id', $courseId);
644 - })
1026 + CourseTopic::whereHas('course', function ($query) use ($courseId) {
1027 + $query->where('id', $courseId);
1028 + })
645 1029 ->where('id', $lessonData['parent_id'])
646 1030 ->firstOrFail();
647 1031
1032 + /** @var CourseLesson $lesson */
648 1033 $lesson = CourseLesson::where('id', $lessionId)
649 1034 ->where('space_id', $courseId)
650 1035 ->firstOrFail();
651 1036
@@ -650,102 +1035,255 @@
650 1035 ->firstOrFail();
651 1036
652 1037 $previousStatus = $lesson->status;
653 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 +
654 1064 $updateData = array_filter([
655 - 'title' => sanitize_text_field(Arr::get($lessonData, 'title')),
656 - 'message' => wp_kses_post(Arr::get($lessonData, 'message')),
657 - 'status' => Arr::get($lessonData, 'status'),
658 - 'meta' => CourseHelper::sanitizeLessonMeta(Arr::get($lessonData, 'meta', []))
1065 + 'title' => sanitize_text_field(Arr::get($lessonData, 'title')),
1066 + 'status' => Arr::get($lessonData, 'status'),
1067 + 'meta' => wp_parse_args($updatedMeta, $lesson->meta),
659 1068 ]);
660 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 +
661 1077 $lesson->fill($updateData);
662 1078 $dirtyFields = $lesson->getDirty();
663 1079
664 - if($dirtyFields) {
1080 + if ($dirtyFields) {
665 1081 $lesson->save();
666 1082 $isNewlyPublished = $lesson->status === 'published' && $previousStatus !== 'published';
667 1083 do_action('fluent_community/lesson/updated', $lesson, $dirtyFields, $isNewlyPublished);
1084 +
1085 + if ($isNewlyPublished) {
1086 + do_action('fluent_community/lesson/published', $lesson);
1087 + }
668 1088 }
669 1089
1090 + do_action('fluent_community/lesson/additional_media_updated', $request->all(), $lesson, $updateData);
1091 +
670 1092 return [
671 1093 'message' => __('Lesson has been updated successfully.', 'fluent-community'),
672 - 'lesson' => $lesson
1094 + 'lesson' => $lesson,
673 1095 ];
674 1096 }
675 1097
676 1098 public function patchLesson(Request $request, $courseId, $lessionId)
677 1099 {
678 - $lesson = CourseLesson::whereHas('course', function($query) use ($courseId) {
679 - $query->where('id', $courseId);
680 - })
1100 + $lesson = CourseLesson::whereHas('course', function ($query) use ($courseId) {
1101 + $query->where('id', $courseId);
1102 + })
681 1103 ->where('id', $lessionId)
682 1104 ->firstOrFail();
683 1105
684 - $acceptedFields = ['title', 'status'];
1106 + $previousStatus = $lesson->status;
685 1107
686 - $lessonData = array_filter($request->only($acceptedFields));
1108 + $acceptedFields = [ 'title', 'status', 'slug' ];
687 1109
688 - $lesson->fill($lessonData);
689 - $lesson->save();
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 + });
690 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 +
691 1164 return [
692 1165 'message' => __('Lesson has been updated successfully.', 'fluent-community'),
693 - 'lesson' => $lesson
1166 + 'lesson' => $lesson,
694 1167 ];
695 1168 }
696 1169
697 1170 public function deleteLesson(Request $request, $courseId, $lessionId)
698 1171 {
699 - $lesson = CourseLesson::whereHas('course', function($query) use ($courseId) {
700 - $query->where('id', $courseId);
701 - })
1172 + $lesson = CourseLesson::whereHas('course', function ($query) use ($courseId) {
1173 + $query->where('id', $courseId);
1174 + })
702 1175 ->where('id', $lessionId)
703 1176 ->firstOrFail();
704 1177
1178 + do_action('fluent_community/lesson/before_deleted', $lesson);
1179 +
705 1180 $lesson->delete();
706 1181
707 1182 return [
708 - 'message' => __('Lesson has been deleted successfully.', 'fluent-community')
1183 + 'message' => __('Lesson has been deleted successfully.', 'fluent-community'),
709 1184 ];
710 1185 }
711 1186
712 - public function updateLockscreenSettings(Request $request, $courseId)
1187 + public function duplicateLesson(Request $request, $courseId, $lessonId)
713 1188 {
714 - $course = Course::findOrFail($courseId);
1189 + Course::findOrFail($courseId);
715 1190
716 - $settingFields = $request->get('lockscreen', []);
1191 + $lesson = CourseLesson::where('id', $lessonId)
1192 + ->where('space_id', $courseId)
1193 + ->firstOrFail();
717 1194
718 - $formattedFields = LockscreenService::formatLockscreenFields($settingFields);
1195 + $siblings = CourseLesson::where('parent_id', $lesson->parent_id)
1196 + ->where('space_id', $courseId)
1197 + ->orderBy('priority', 'ASC')
1198 + ->orderBy('id', 'ASC')
1199 + ->get();
719 1200
720 - $course->setLockscreen($formattedFields);
1201 + $sourceIndex = $siblings->search(function ($sibling) use ($lesson) {
1202 + return (int)$sibling->id === (int)$lesson->id;
1203 + });
721 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 +
722 1240 return [
723 - 'message' => __('Lockscreen settings have been updated successfully.', 'fluent-community'),
724 - 'course' => $course
1241 + 'message' => __('Lesson has been duplicated successfully.', 'fluent-community'),
1242 + 'lesson' => $newLesson,
725 1243 ];
726 1244 }
727 1245
728 - public function getOtherUsers(Request $request)
1246 + public function getOtherUsers(Request $request, $courseId)
729 1247 {
730 - $this->validate($request->all(), [
731 - 'course_id' => 'required|exists:fcom_spaces,id'
732 - ]);
1248 + $selects = [
1249 + 'ID',
1250 + 'display_name',
1251 + ];
733 1252
734 - $courseId = (int)$request->get('course_id');
1253 + if (current_user_can('list_users')) {
1254 + $selects[] = 'user_email';
1255 + }
735 1256
736 - $search = $request->getSafe('search');
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'));
737 1263
738 - $users = User::whereDoesntHave('courses', function ($q) use ($courseId) {
739 - $q->where('space_id', $courseId);
740 - })
741 - ->select(['ID', 'display_name'])
742 - ->searchBy($search)
743 - ->paginate();
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 + }
744 1272
745 - return [
746 - 'users' => $users
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,
747 1283 ];
1284 +
1285 + return apply_filters('fluent_community/admin_course_non_members_api_response', $data, $request->all());
748 1286 }
749 1287
750 1288 public function updateLinks(Request $request, $id)
751 1289 {
@@ -762,8 +1300,51 @@
762 1300 $course->save();
763 1301
764 1302 return [
765 1303 'message' => __('Links have been updated for the course', 'fluent-community'),
766 - 'links' => $links
1304 + 'links' => $links,
767 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());
768 1349 }
769 1350 }