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