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