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