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