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 +793 -359 1.0.932.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) {
@@ -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,94 @@
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 + CourseHelper::resetCourseProgress($courseId, (int) $studentId);
407 612
408 - do_action('fluent_community/course/remove_student', $course, $studentId, 'by_admin');
409 -
410 613 return [
411 - 'message' => __('Student has been removed from this course', 'fluent-community')
614 + 'message' => __("Student's progress has been reset.", 'fluent-community'),
412 615 ];
413 616 }
414 617
415 618 public function getSections(Request $request, $courseId)
416 619 {
417 - $course = Course::byAdminAccess(get_current_user_id())
418 - ->where('id', $courseId)
419 - ->first();
620 + $course = Course::findOrFail($courseId);
621 + /** @var Course $course */
420 622
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 623 $sectionsQuery = CourseTopic::where('space_id', $courseId)
428 - ->orderBy('priority', 'ASC');
624 + ->orderBy('priority', 'ASC')
625 + ->orderBy('id', 'ASC');
429 626
430 627 if (in_array('only_published', $request->get('conditions', []))) {
431 628 $sectionsQuery->where('status', 'published')
432 - ->with(['lessons' => function ($q) {
629 + ->with([
630 + 'lessons' => function ($q) {
433 631 $q->where('status', 'published');
434 - }]);
632 + },
633 + ]);
435 634 }
436 635
437 636 if (empty($request->get('conditions', []))) {
438 - $sectionsQuery->with(['lessons']);
637 + $sectionsQuery->with([ 'lessons' ]);
439 638 }
440 639
441 640 $sections = $sectionsQuery->get();
442 641
443 642 $data = [
444 - 'sections' => $sections
643 + 'sections' => $sections,
445 644 ];
446 645
447 646 if ($request->get('with_lock_screen')) {
448 647 $data['lockscreen'] = LockscreenService::getLockscreenSettings($course);
@@ -447,37 +646,26 @@
447 646 if ($request->get('with_lock_screen')) {
448 647 $data['lockscreen'] = LockscreenService::getLockscreenSettings($course);
449 648 }
450 649
451 - return $data;
650 + return apply_filters('fluent_community/admin_course_sections_api_response', $data, $request->all());
452 651 }
453 652
454 653 public function getSection(Request $request, $courseId, $topicId)
455 654 {
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 655 $topic = CourseTopic::where('space_id', $courseId)
656 + ->whereHas('course', function ($query) use ($courseId) {
657 + $query->where('id', $courseId);
658 + })
467 659 ->where('id', $topicId)
468 - ->with(['lessons'])
469 - ->first();
660 + ->with([ 'lessons' ])
661 + ->firstOrFail();
470 662
471 - if (!$topic) {
472 - return $this->sendError([
473 - 'message' => __('Topic could not be found.', 'fluent-community')
474 - ]);
475 - }
663 + $data = [
664 + 'topic' => $topic,
665 + ];
476 666
477 - return [
478 - 'topic' => $topic
479 - ];
667 + return apply_filters('fluent_community/admin_course_section_api_response', $data, $request->all());
480 668 }
481 669
482 670 public function resetSectionIndexes(Request $request, $courseId)
483 671 {
@@ -487,17 +675,17 @@
487 675 ->whereIn('id', array_keys($indexes))
488 676 ->get();
489 677
490 678 foreach ($sections as $section) {
491 - if (!isset($indexes[$section->id])) continue;
679 + if (!isset($indexes[$section->id])) { continue;
680 + }
492 681 $section->priority = $indexes[$section->id];
493 682 $section->save();
494 683 }
495 684
496 -
497 685 return [
498 686 'sections' => $sections,
499 - 'message' => __('Sections indexes has been updated successfully.', 'fluent-community')
687 + 'message' => __('Section indexes have been updated successfully.', 'fluent-community'),
500 688 ];
501 689 }
502 690
503 691 public function resetLessonIndexes(Request $request, $courseId, $sectionId)
@@ -509,9 +697,10 @@
509 697 ->whereIn('id', array_keys($indexes))
510 698 ->get();
511 699
512 700 foreach ($lessons as $lesson) {
513 - if (!isset($indexes[$lesson->id])) continue;
701 + if (!isset($indexes[$lesson->id])) { continue;
702 + }
514 703 $lesson->priority = $indexes[$lesson->id];
515 704 $lesson->save();
516 705 }
517 706
@@ -516,41 +705,57 @@
516 705 }
517 706
518 707 return [
519 708 'lessons' => $lessons,
520 - 'message' => __('Lessons indexes has been updated successfully.', 'fluent-community')
709 + 'message' => __('Lesson indexes have been updated successfully.', 'fluent-community'),
521 710 ];
522 711 }
523 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 +
524 731 public function createSection(Request $request, $courseId)
525 732 {
526 733 $this->validate($request->all(), [
527 - 'title' => 'required'
734 + 'title' => 'required',
528 735 ]);
529 736
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 737 $sectionData = [
541 - 'title' => $request->getSafe('title', 'sanitize_text_field'),
738 + 'title' => $request->getSafe('title'),
542 739 'space_id' => $courseId,
543 - 'status' => 'published'
740 + 'status' => 'published',
544 741 ];
545 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 +
546 749 $section = CourseTopic::create($sectionData);
547 750
548 751 $section->load('lessons');
549 752
753 + do_action('fluent_community/section/created', $section, $course);
754 +
550 755 return [
551 - 'message' => __('Topic has been created successfully.', 'fluent-community'),
552 - 'section' => $section
756 + 'message' => __('Section has been created successfully.', 'fluent-community'),
757 + 'section' => $section,
553 758 ];
554 759 }
555 760
556 761 public function updateSection(Request $request, $courseId, $tipicId)
@@ -556,343 +761,529 @@
556 761 public function updateSection(Request $request, $courseId, $tipicId)
557 762 {
558 763 $this->validate($request->all(), [
559 764 'title' => 'required',
560 - 'status' => 'required|in:draft,published,archived'
765 + 'status' => 'required|in:draft,published,archived',
561 766 ]);
562 767
563 - $course = Course::byAdminAccess(get_current_user_id())
564 - ->where('id', $courseId)
565 - ->first();
768 + Course::findOrFail($courseId);
566 769
567 770 $topic = CourseTopic::where('space_id', $courseId)
568 771 ->where('id', $tipicId)
569 - ->first();
772 + ->firstOrFail();
570 773
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 774
577 -
578 775 $topicData = [
579 - 'title' => $request->getSafe('title', 'sanitize_text_field'),
580 - 'status' => $request->get('status')
776 + 'title' => $request->getSafe('title'),
777 + 'status' => $request->get('status'),
581 778 ];
582 779
583 - $course->update($topicData);
780 + $topic->update($topicData);
584 781
585 782 return [
586 783 'message' => __('Topic has been updated successfully.', 'fluent-community'),
587 - 'topic' => $topic
784 + 'topic' => $topic,
588 785 ];
589 786 }
590 787
591 788 public function patchSection(Request $request, $courseId, $tipicId)
592 789 {
593 - $course = Course::byAdminAccess(get_current_user_id())
594 - ->where('id', $courseId)
595 - ->first();
790 + $course = Course::findOrFail($courseId);
596 791
597 792 $topic = CourseTopic::where('space_id', $courseId)
598 793 ->where('id', $tipicId)
599 - ->first();
794 + ->firstOrFail();
600 795
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 - }
796 + $acceptedFields = [ 'title', 'status' ];
606 797
607 - $acceptedFields = ['title', 'status'];
608 -
609 798 if ($course->getCourseType() == 'scheduled') {
610 799 $acceptedFields[] = 'scheduled_at';
611 - } else if ($course->getCourseType() == 'structured') {
800 + } elseif ($course->getCourseType() == 'structured') {
612 801 $acceptedFields[] = 'reactions_count';
613 802 }
614 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);
615 808
616 - $topicData = $request->only($acceptedFields);
809 + if (isset($topicData['title'])) {
810 + $topicData['title'] = sanitize_text_field($topicData['title']);
811 + }
617 812
813 + if (isset($topicData['status'])) {
814 + $topicData['status'] = sanitize_text_field($topicData['status']);
815 + }
816 +
618 817 if (!empty($topicData['scheduled_at'])) {
619 818 $topic->reactions_count = 0;
620 - } else if (isset($topicData['reactions_count'])) {
819 + } elseif (isset($topicData['reactions_count'])) {
621 820 $topic->scheduled_at = null;
622 821 $topic->reactions_count = $topicData['reactions_count'];
623 822 }
624 823
625 - $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 +
626 830 $topic->fill($topicData);
831 +
832 + $scheduledAtDirty = $topic->isDirty('scheduled_at');
833 + $reactionsCountDirty = $topic->isDirty('reactions_count');
834 +
627 835 $topic->save();
628 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 +
629 844 return [
630 845 'message' => __('Topic has been updated successfully.', 'fluent-community'),
631 - 'topic' => $topic
846 + 'topic' => $topic,
632 847 ];
633 848 }
634 849
635 - public function deleteSection(Request $request, $courseId, $sectionId)
850 + public function copySection(Request $request, $toCourseId)
636 851 {
637 - $course = Course::byAdminAccess(get_current_user_id())
638 - ->where('id', $courseId)
639 - ->first();
852 + $sectionId = $request->getSafe('section_id', 'intval');
853 + $fromCourseId = $request->getSafe('from_course_id', 'intval');
640 854
641 - $topic = CourseTopic::where('space_id', $courseId)
642 - ->where('id', $sectionId)
643 - ->first();
855 + $fromCourse = Course::findOrFail($fromCourseId);
644 856
645 - if (!$course && !$topic) {
857 + if (!$fromCourse->isCourseAdmin()) {
646 858 return $this->sendError([
647 - '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'),
648 860 ]);
649 861 }
650 862
651 - CourseTopic::where('id', $sectionId)
652 - ->where('space_id', $courseId)
653 - ->delete();
863 + /** @var CourseTopic $originalSection */
864 + $originalSection = CourseTopic::where('id', $sectionId)
865 + ->where('space_id', $fromCourseId)
866 + ->firstOrFail();
654 867
655 - CourseLesson::where('parent_id', $sectionId)
656 - ->where('space_id', $courseId)
657 - ->delete();
868 + $toCourse = Course::findOrFail($toCourseId);
658 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 +
659 894 return [
660 - 'message' => __('Section has been deleted successfully.', 'fluent-community')
895 + 'message' => __('Section has been copied to the selected course', 'fluent-community'),
896 + 'section' => $newSection,
661 897 ];
662 898 }
663 899
664 - public function getLessons(Request $request, $courseId)
900 + public function deleteSection(Request $request, $courseId, $sectionId)
665 901 {
666 - $course = Course::byAdminAccess(get_current_user_id())
667 - ->where('id', $courseId)
668 - ->first();
902 + $topic = CourseTopic::where([
903 + 'id' => $sectionId,
904 + 'space_id' => $courseId,
905 + ])->firstOrFail();
669 906
670 - if (!$course) {
671 - return $this->sendError([
672 - 'message' => __('Course not found or you do not have access to this course.', 'fluent-community')
673 - ]);
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();
674 919 }
675 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 +
676 930 $lessons = CourseLesson::where('space_id', $courseId)
677 - ->orderBy('priority', 'ASC');
931 + ->orderBy('priority', 'ASC')
932 + ->orderBy('id', 'ASC');
678 933
679 - if ($request->get('topic_id')) {
680 - $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);
681 938 }
682 939
683 940 $lessons = $lessons->get();
684 941
685 - return [
686 - 'lessons' => $lessons
942 + $data = [
943 + 'lessons' => $lessons,
687 944 ];
945 +
946 + return apply_filters('fluent_community/admin_course_lessons_api_response', $data, $request->all());
688 947 }
689 948
690 949 public function getLesson(Request $request, $courseId, $lessonId)
691 950 {
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)
951 + $lesson = CourseLesson::whereHas('course', function ($query) use ($courseId) {
952 + $query->where('id', $courseId);
953 + })
703 954 ->where('id', $lessonId)
704 - ->with(['topic', 'course'])
705 - ->first();
955 + ->with([ 'topic', 'course' ])
956 + ->firstOrFail();
706 957
707 - if (!$lesson) {
708 - return $this->sendError([
709 - 'message' => __('Lesson could not be found.', 'fluent-community')
710 - ]);
711 - }
958 + $data = [
959 + 'lesson' => $lesson,
960 + ];
712 961
713 - return [
714 - 'lesson' => $lesson
715 - ];
962 + return apply_filters('fluent_community/admin_course_lesson_api_response', $data, $request->all());
716 963 }
717 964
965 + /**
966 + * Expects `title` and `section_id` at the top level of the request.
967 + */
718 968 public function createLesson(Request $request, $courseId)
719 969 {
720 970 $this->validate($request->all(), [
721 971 'title' => 'required',
722 - 'section_id' => 'required'
972 + 'section_id' => 'required',
723 973 ]);
724 974
725 - $course = Course::byAdminAccess(get_current_user_id())
726 - ->where('id', $courseId)
727 - ->first();
975 + $sectionId = (int)$request->get('section_id');
728 976
729 - $topic = CourseTopic::where('id', $request->get('section_id'))
730 - ->where('space_id', $courseId)
731 - ->first();
977 + $topic = CourseTopic::whereHas('course', function ($query) use ($courseId) {
978 + $query->where('id', $courseId);
979 + })
980 + ->where('id', $sectionId)
981 + ->firstOrFail();
732 982
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 983 $lessonData = [
740 - 'title' => $request->getSafe('title', 'sanitize_text_field'),
984 + 'title' => $request->getSafe('title'),
741 985 'parent_id' => $topic->id,
742 986 'space_id' => $courseId,
743 - 'status' => 'draft'
987 + 'status' => 'draft',
744 988 ];
745 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 +
746 999 $lesson = CourseLesson::create($lessonData);
747 1000
748 1001 $lesson = CourseLesson::findOrFail($lesson->id);
749 1002
1003 + do_action('fluent_community/lesson/created', $lesson, $topic);
1004 +
750 1005 return [
751 1006 'message' => __('Lesson has been created successfully.', 'fluent-community'),
752 - 'lesson' => $lesson
1007 + 'lesson' => $lesson,
753 1008 ];
754 1009 }
755 1010
1011 + /**
1012 + * Expects the fields nested under `lesson`, referencing the section as `parent_id`.
1013 + */
756 1014 public function updateLesson(Request $request, $courseId, $lessionId)
757 1015 {
758 - $lessonData = $request->get('lesson');
1016 + Course::findOrFail($courseId);
759 1017
1018 + $lessonData = (array)$request->get('lesson');
1019 +
760 1020 $this->validate($lessonData, [
761 1021 'title' => 'required',
762 1022 'parent_id' => 'required',
763 - 'status' => 'required|in:draft,published,archived'
1023 + 'status' => 'required|in:draft,published,archived',
764 1024 ]);
765 1025
766 - $course = Course::byAdminAccess(get_current_user_id())
767 - ->where('id', $courseId)
768 - ->first();
1026 + CourseTopic::whereHas('course', function ($query) use ($courseId) {
1027 + $query->where('id', $courseId);
1028 + })
1029 + ->where('id', $lessonData['parent_id'])
1030 + ->firstOrFail();
769 1031
770 - $topic = CourseTopic::where('id', $lessonData['parent_id'])
1032 + /** @var CourseLesson $lesson */
1033 + $lesson = CourseLesson::where('id', $lessionId)
771 1034 ->where('space_id', $courseId)
772 - ->first();
1035 + ->firstOrFail();
773 1036
774 - $lesson = CourseLesson::where('id', $lessionId)
775 - ->where('space_id', $courseId)
776 - ->first();
1037 + $previousStatus = $lesson->status;
777 1038
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 - ]);
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;
782 1062 }
783 1063
784 1064 $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', []))
1065 + 'title' => sanitize_text_field(Arr::get($lessonData, 'title')),
1066 + 'status' => Arr::get($lessonData, 'status'),
1067 + 'meta' => wp_parse_args($updatedMeta, $lesson->meta),
789 1068 ]);
790 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 +
791 1077 $lesson->fill($updateData);
792 - $lesson->save();
1078 + $dirtyFields = $lesson->getDirty();
793 1079
1080 + if ($dirtyFields) {
1081 + $lesson->save();
1082 + $isNewlyPublished = $lesson->status === 'published' && $previousStatus !== 'published';
1083 + do_action('fluent_community/lesson/updated', $lesson, $dirtyFields, $isNewlyPublished);
1084 +
1085 + if ($isNewlyPublished) {
1086 + do_action('fluent_community/lesson/published', $lesson);
1087 + }
1088 + }
1089 +
1090 + do_action('fluent_community/lesson/additional_media_updated', $request->all(), $lesson, $updateData);
1091 +
794 1092 return [
795 1093 'message' => __('Lesson has been updated successfully.', 'fluent-community'),
796 - 'lesson' => $lesson
1094 + 'lesson' => $lesson,
797 1095 ];
798 1096 }
799 1097
800 1098 public function patchLesson(Request $request, $courseId, $lessionId)
801 1099 {
802 - $course = Course::byAdminAccess(get_current_user_id())
803 - ->where('id', $courseId)
804 - ->first();
1100 + $lesson = CourseLesson::whereHas('course', function ($query) use ($courseId) {
1101 + $query->where('id', $courseId);
1102 + })
1103 + ->where('id', $lessionId)
1104 + ->firstOrFail();
805 1105
806 - $lesson = CourseLesson::where('id', $lessionId)
807 - ->where('space_id', $courseId)
808 - ->first();
1106 + $previousStatus = $lesson->status;
809 1107
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 - ]);
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']);
814 1120 }
815 1121
816 - $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 + }
817 1132
818 - $lessonData = array_filter($request->only($acceptedFields));
1133 + if (isset($lessonData['status']) && !in_array($lessonData['status'], ['draft', 'published', 'archived'], true)) {
1134 + unset($lessonData['status']);
1135 + }
819 1136
820 - $lesson->fill($lessonData);
821 - $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 + }
822 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 +
823 1164 return [
824 1165 'message' => __('Lesson has been updated successfully.', 'fluent-community'),
825 - 'lesson' => $lesson
1166 + 'lesson' => $lesson,
826 1167 ];
827 1168 }
828 1169
829 1170 public function deleteLesson(Request $request, $courseId, $lessionId)
830 1171 {
831 - $course = Course::byAdminAccess(get_current_user_id())
832 - ->where('id', $courseId)
833 - ->first();
1172 + $lesson = CourseLesson::whereHas('course', function ($query) use ($courseId) {
1173 + $query->where('id', $courseId);
1174 + })
1175 + ->where('id', $lessionId)
1176 + ->firstOrFail();
834 1177
835 - $lesson = CourseLesson::where('id', $lessionId)
836 - ->where('space_id', $courseId)
837 - ->first();
1178 + do_action('fluent_community/lesson/before_deleted', $lesson);
838 1179
839 - if (!$lesson || !$course) {
840 - return $this->sendError([
841 - 'message' => __('Lesson not found.', 'fluent-community')
842 - ]);
843 - }
844 -
845 1180 $lesson->delete();
846 1181
847 1182 return [
848 - 'message' => __('Lesson has been deleted successfully.', 'fluent-community')
1183 + 'message' => __('Lesson has been deleted successfully.', 'fluent-community'),
849 1184 ];
850 1185 }
851 1186
852 - public function updateLockscreenSettings(Request $request, $courseId)
1187 + public function duplicateLesson(Request $request, $courseId, $lessonId)
853 1188 {
854 - $course = Course::byAdminAccess(get_current_user_id())
855 - ->where('id', $courseId)
856 - ->first();
1189 + Course::findOrFail($courseId);
857 1190
858 - if (!$course) {
859 - return $this->sendError([
860 - 'message' => __('Course not found or you do not have access to this course.', 'fluent-community')
861 - ]);
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;
862 1207 }
863 1208
864 - $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 + }
865 1218
866 - $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();
867 1225
868 - $course->setLockscreen($formattedFields);
1226 + $orderedIds = $siblings->pluck('id')->toArray();
1227 + array_splice($orderedIds, $sourceIndex + 1, 0, [ $newLesson->id ]);
869 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 +
870 1240 return [
871 - 'message' => __('Lockscreen settings has been updated successfully.', 'fluent-community'),
872 - 'course' => $course
1241 + 'message' => __('Lesson has been duplicated successfully.', 'fluent-community'),
1242 + 'lesson' => $newLesson,
873 1243 ];
874 1244 }
875 1245
876 - public function getOtherUsers(Request $request)
1246 + public function getOtherUsers(Request $request, $courseId)
877 1247 {
878 - $this->validate($request->all(), [
879 - 'course_id' => 'required|exists:fcom_spaces,id'
880 - ]);
1248 + $selects = [
1249 + 'ID',
1250 + 'display_name',
1251 + ];
881 1252
882 - $courseId = (int)$request->get('course_id');
1253 + if (current_user_can('list_users')) {
1254 + $selects[] = 'user_email';
1255 + }
883 1256
884 - $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'));
885 1263
886 - $users = User::whereDoesntHave('courses', function ($q) use ($courseId) {
887 - $q->where('space_id', $courseId);
888 - })
889 - ->select(['ID', 'display_name'])
890 - ->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 + }
891 1272
892 - return [
893 - '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,
894 1283 ];
1284 +
1285 + return apply_filters('fluent_community/admin_course_non_members_api_response', $data, $request->all());
895 1286 }
896 1287
897 1288 public function updateLinks(Request $request, $id)
898 1289 {
@@ -908,9 +1299,52 @@
908 1299 $course->settings = $settings;
909 1300 $course->save();
910 1301
911 1302 return [
912 - 'message' => __('Links has been updated for the course', 'fluent-community'),
913 - 'links' => $links
1303 + 'message' => __('Links have been updated for the course', 'fluent-community'),
1304 + 'links' => $links,
914 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());
915 1349 }
916 1350 }