PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.7
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.7
4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 4.2.1 All 138 releases
learnpress / inc / MCP / Domain / CourseTools.php

CourseTools.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.7, at inc/MCP/Domain/CourseTools.php

496 lines 14.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace LearnPress\MCP\Domain;
4
5 use LearnPress\MCP\Mappers\ResponseMapper;
6 use LearnPress\MCP\Support\Errors;
7 use LearnPress\MCP\Support\Pagination;
8 use LearnPress\MCP\Support\Permissions;
9 use LearnPress\MCP\Support\Sanitizer;
10 use LearnPress\MCP\Support\Validator;
11 use LearnPress\Filters\Course\CourseJsonFilter;
12 use LearnPress\Models\CourseModel;
13 use LearnPress\Models\CoursePostModel;
14 use LearnPress\Models\Courses;
15 use LearnPress\Services\CourseService;
16 use LP_Helper;
17 use Throwable;
18 use WP_Error;
19
20 defined( 'ABSPATH' ) || exit;
21
22 /**
23 * Course create/update/delete executors.
24 *
25 * Uses LearnPress course models, the CourseService create helper, and standard
26 * LearnPress course meta keys. Delete is reversible (trash only).
27 */
28 class CourseTools {
29
30 /**
31 * Execute `learnpress/create-course`.
32 *
33 * @param mixed $input Ability input.
34 *
35 * @return array|WP_Error
36 */
37 public static function create_course( $input ) {
38 $args = is_array( $input ) ? $input : array();
39 $title = Sanitizer::text( $args['title'] ?? '' );
40 if ( '' === $title ) {
41 return Errors::invalid( __( 'title is required.', 'learnpress' ) );
42 }
43
44 if ( ! Permissions::can_create( LP_COURSE_CPT ) ) {
45 return Errors::forbidden();
46 }
47
48 $status = Validator::status( $args['status'] ?? '' );
49 if ( is_wp_error( $status ) ) {
50 return $status;
51 }
52
53 $terms = self::resolve_terms( $args );
54 if ( is_wp_error( $terms ) ) {
55 return $terms;
56 }
57
58 $thumbnail_id = self::resolve_thumbnail( $args );
59 if ( is_wp_error( $thumbnail_id ) ) {
60 return $thumbnail_id;
61 }
62
63 $instructor_id = absint( $args['instructor_id'] ?? 0 );
64 $author_id = $instructor_id > 0 ? $instructor_id : get_current_user_id();
65
66 $data = array(
67 'post_title' => $title,
68 'post_content' => Sanitizer::html( $args['description'] ?? '' ),
69 'post_excerpt' => Sanitizer::html( $args['excerpt'] ?? '' ),
70 'post_status' => '' !== $status ? $status : 'draft',
71 'post_author' => $author_id,
72 'meta_input' => self::build_meta( $args ),
73 );
74
75 try {
76 $course_post = CourseService::instance()->create_info_main( $data );
77 $course_id = $course_post->get_id();
78 if ( $course_id <= 0 ) {
79 return Errors::internal();
80 }
81
82 self::apply_terms( $course_id, $terms );
83 if ( $thumbnail_id > 0 ) {
84 set_post_thumbnail( $course_id, $thumbnail_id );
85 }
86
87 $course = CourseModel::find( $course_id, false );
88 if ( ! $course instanceof CourseModel ) {
89 return Errors::internal();
90 }
91
92 return array( 'course' => ResponseMapper::course_write_result( $course ) );
93 } catch ( Throwable $e ) {
94 return Errors::from_throwable( $e );
95 }
96 }
97
98 /**
99 * Execute `learnpress/update-course`.
100 *
101 * @param mixed $input Ability input.
102 *
103 * @return array|WP_Error
104 */
105 public static function update_course( $input ) {
106 $args = is_array( $input ) ? $input : array();
107 $course_id = Validator::require_id( $args, 'course_id' );
108 if ( is_wp_error( $course_id ) ) {
109 return $course_id;
110 }
111
112 $course_post = CoursePostModel::find( $course_id, true );
113 if ( ! $course_post instanceof CoursePostModel ) {
114 return Errors::not_found( __( 'Course not found.', 'learnpress' ) );
115 }
116
117 if ( ! Permissions::can_edit_post( $course_id ) ) {
118 return Errors::forbidden();
119 }
120
121 $status = Validator::status( $args['status'] ?? '' );
122 if ( is_wp_error( $status ) ) {
123 return $status;
124 }
125
126 $terms = self::resolve_terms( $args );
127 if ( is_wp_error( $terms ) ) {
128 return $terms;
129 }
130
131 $thumbnail_id = self::resolve_thumbnail( $args );
132 if ( is_wp_error( $thumbnail_id ) ) {
133 return $thumbnail_id;
134 }
135
136 try {
137 if ( array_key_exists( 'title', $args ) ) {
138 $course_post->post_title = Sanitizer::text( $args['title'] );
139 }
140 if ( array_key_exists( 'description', $args ) ) {
141 $course_post->post_content = Sanitizer::html( $args['description'] );
142 }
143 if ( array_key_exists( 'excerpt', $args ) ) {
144 $course_post->post_excerpt = Sanitizer::html( $args['excerpt'] );
145 }
146 if ( '' !== $status ) {
147 $course_post->post_status = $status;
148 }
149 if ( ! empty( $args['instructor_id'] ) ) {
150 $course_post->post_author = absint( $args['instructor_id'] );
151 }
152
153 // Stage meta before save() so the synchronous learnpress_courses
154 // snapshot (rebuilt on save_post) reflects the new values; otherwise
155 // the update response would read stale meta from the old snapshot.
156 foreach ( self::build_meta( $args ) as $key => $value ) {
157 $course_post->meta_data->{$key} = $value;
158 }
159
160 $course_post->save();
161
162 self::apply_terms( $course_id, $terms );
163 if ( $thumbnail_id > 0 ) {
164 set_post_thumbnail( $course_id, $thumbnail_id );
165 }
166
167 $course = CourseModel::find( $course_id, false );
168 if ( ! $course instanceof CourseModel ) {
169 return Errors::internal();
170 }
171
172 return array( 'course' => ResponseMapper::course_object( $course ) );
173 } catch ( Throwable $e ) {
174 return Errors::from_throwable( $e );
175 }
176 }
177
178 /**
179 * Execute `learnpress/delete-course` (reversible trash only).
180 *
181 * @param mixed $input Ability input.
182 *
183 * @return array|WP_Error
184 */
185 public static function delete_course( $input ) {
186 $args = is_array( $input ) ? $input : array();
187 $course_id = Validator::require_id( $args, 'course_id' );
188 if ( is_wp_error( $course_id ) ) {
189 return $course_id;
190 }
191
192 $course = CourseModel::find( $course_id, true );
193 if ( ! $course instanceof CourseModel ) {
194 return Errors::not_found( __( 'Course not found.', 'learnpress' ) );
195 }
196
197 if ( ! Permissions::can_delete_post( $course_id ) ) {
198 return Errors::forbidden();
199 }
200
201 $previous_status = (string) $course->get_status();
202
203 try {
204 $trashed = wp_trash_post( $course_id );
205 if ( ! $trashed ) {
206 return Errors::internal();
207 }
208
209 return array(
210 'trashed' => true,
211 'course_id' => $course_id,
212 'previous_status' => $previous_status,
213 'recovery' => array(
214 'title' => (string) $course->get_title(),
215 'previous_status' => $previous_status,
216 'note' => __( 'Course moved to trash. Restore from WP admin to recover.', 'learnpress' ),
217 ),
218 );
219 } catch ( Throwable $e ) {
220 return Errors::from_throwable( $e );
221 }
222 }
223
224 /**
225 * Execute `learnpress/get-courses`.
226 *
227 * @param mixed $input Ability input.
228 *
229 * @return array|WP_Error
230 */
231 public static function get_courses( $input ) {
232 $args = Sanitizer::input_array( $input, 'learnpress/get-courses' );
233 if ( is_wp_error( $args ) ) {
234 return $args;
235 }
236
237 $page = Pagination::page( $args['page'] ?? 1 );
238 $per_page = Pagination::per_page( $args['per_page'] ?? 10 );
239 $filter = new CourseJsonFilter();
240 $filter->page = $page;
241 $filter->limit = $per_page;
242 $filter->post_status = Sanitizer::status_list( $args['status'] ?? array( 'publish' ) );
243 if ( empty( $filter->post_status ) ) {
244 $filter->post_status = array( 'publish' );
245 }
246
247 if ( ! empty( $args['category'] ) ) {
248 $filter->term_ids = array( absint( $args['category'] ) );
249 }
250 if ( ! empty( $args['instructor'] ) ) {
251 $filter->post_author = absint( $args['instructor'] );
252 }
253 if ( isset( $args['search'] ) ) {
254 $filter->post_title = LP_Helper::sanitize_params_submitted( (string) $args['search'] );
255 }
256
257 $price_min = isset( $args['price_min'] ) && is_numeric( $args['price_min'] ) ? (float) $args['price_min'] : null;
258 $price_max = isset( $args['price_max'] ) && is_numeric( $args['price_max'] ) ? (float) $args['price_max'] : null;
259 if ( null !== $price_min && $price_min < 0 ) {
260 return Errors::invalid( __( 'price_min must be greater than or equal to 0.', 'learnpress' ) );
261 }
262 if ( null !== $price_max && $price_max < 0 ) {
263 return Errors::invalid( __( 'price_max must be greater than or equal to 0.', 'learnpress' ) );
264 }
265 if ( null !== $price_min && null !== $price_max && $price_min > $price_max ) {
266 return Errors::invalid( __( 'price_min must not be greater than price_max.', 'learnpress' ) );
267 }
268
269 global $wpdb;
270 if ( null !== $price_min && $wpdb ) {
271 $filter->where[] = $wpdb->prepare( 'AND c.price_to_sort >= %f', $price_min );
272 }
273 if ( null !== $price_max && $wpdb ) {
274 $filter->where[] = $wpdb->prepare( 'AND c.price_to_sort <= %f', $price_max );
275 }
276
277 try {
278 $total_rows = 0;
279 $rows = Courses::get_list_courses( $filter, $total_rows );
280 $rows = is_array( $rows ) ? $rows : array();
281 $items = array();
282 foreach ( $rows as $row ) {
283 $course = CourseModel::find( absint( $row->ID ?? 0 ), true );
284 if ( $course instanceof CourseModel ) {
285 $items[] = ResponseMapper::course_summary( $course );
286 }
287 }
288
289 return array(
290 'items' => $items,
291 'pagination' => array(
292 'page' => $page,
293 'per_page' => $per_page,
294 'total_items' => (int) $total_rows,
295 'total_pages' => Pagination::total_pages( (int) $total_rows, $per_page ),
296 ),
297 );
298 } catch ( Throwable $e ) {
299 return Errors::internal();
300 }
301 }
302
303 /**
304 * Execute `learnpress/get-course-details`.
305 *
306 * @param mixed $input Ability input.
307 *
308 * @return array|WP_Error
309 */
310 public static function get_course_details( $input ) {
311 $args = Sanitizer::input_array( $input, 'learnpress/get-course-details' );
312 if ( is_wp_error( $args ) ) {
313 return $args;
314 }
315
316 $course_id = Validator::require_id( $args, 'course_id' );
317 if ( is_wp_error( $course_id ) ) {
318 return $course_id;
319 }
320
321 $course = CourseModel::find( $course_id, true );
322 if ( ! $course instanceof CourseModel ) {
323 return Errors::not_found( __( 'Course not found.', 'learnpress' ) );
324 }
325
326 $sections = array();
327 foreach ( $course->get_section_items() as $section ) {
328 $items = array();
329 foreach ( $section->items as $item ) {
330 $items[] = array(
331 'item_id' => absint( $item->item_id ?? $item->id ?? 0 ),
332 'item_type' => (string) ( $item->item_type ?? $item->type ?? '' ),
333 'title' => (string) ( $item->title ?? '' ),
334 'preview' => ! empty( $item->preview ),
335 );
336 }
337 $sections[] = array(
338 'section_id' => absint( $section->section_id ?? $section->id ?? 0 ),
339 'section_name' => (string) ( $section->section_name ?? $section->title ?? '' ),
340 'section_description' => (string) ( $section->section_description ?? $section->description ?? '' ),
341 'items' => $items,
342 );
343 }
344
345 $detail = ResponseMapper::course_summary( $course );
346 $detail['description'] = (string) $course->get_description();
347 // Requirements are stored as an array (repeatable field); return as-is, not cast to "Array".
348 $detail['requirements'] = $course->get_meta_value_by_key( CoursePostModel::META_KEY_REQUIREMENTS, '' );
349 $detail['curriculum'] = array(
350 'sections' => $sections,
351 'items_count' => (int) $course->count_items(),
352 );
353
354 return array( 'course' => $detail );
355 }
356
357 /**
358 * Build LearnPress course meta from provided write fields.
359 *
360 * @param array $args Input args.
361 *
362 * @return array
363 */
364 protected static function build_meta( array $args ): array {
365 $meta = array();
366
367 if ( array_key_exists( 'price', $args ) ) {
368 $meta[ CoursePostModel::META_KEY_REGULAR_PRICE ] = (float) $args['price'];
369 }
370 if ( array_key_exists( 'sale_price', $args ) ) {
371 $meta[ CoursePostModel::META_KEY_SALE_PRICE ] = (float) $args['sale_price'];
372 }
373 if ( array_key_exists( 'duration', $args ) ) {
374 $meta[ CoursePostModel::META_KEY_DURATION ] = Sanitizer::text( $args['duration'] );
375 }
376 if ( array_key_exists( 'level', $args ) ) {
377 $meta[ CoursePostModel::META_KEY_LEVEL ] = Sanitizer::text( $args['level'] );
378 }
379 if ( array_key_exists( 'requirements', $args ) && is_array( $args['requirements'] ) ) {
380 $meta[ CoursePostModel::META_KEY_REQUIREMENTS ] = array_map( array( Sanitizer::class, 'text' ), $args['requirements'] );
381 }
382 if ( array_key_exists( 'target_audiences', $args ) && is_array( $args['target_audiences'] ) ) {
383 $meta[ CoursePostModel::META_KEY_TARGET ] = array_map( array( Sanitizer::class, 'text' ), $args['target_audiences'] );
384 }
385 if ( array_key_exists( 'features', $args ) && is_array( $args['features'] ) ) {
386 $meta[ CoursePostModel::META_KEY_FEATURES ] = array_map( array( Sanitizer::class, 'text' ), $args['features'] );
387 }
388 if ( array_key_exists( 'faqs', $args ) && is_array( $args['faqs'] ) ) {
389 $faqs = array();
390 foreach ( $args['faqs'] as $faq ) {
391 if ( ! is_array( $faq ) ) {
392 continue;
393 }
394 $faqs[] = array(
395 'question' => Sanitizer::text( $faq['question'] ?? '' ),
396 'answer' => Sanitizer::html( $faq['answer'] ?? '' ),
397 );
398 }
399 $meta[ CoursePostModel::META_KEY_FAQS ] = $faqs;
400 }
401
402 return $meta;
403 }
404
405 /**
406 * Validate category/tag IDs against their taxonomies.
407 *
408 * @param array $args Input args.
409 *
410 * @return array|WP_Error { categories: int[], tags: int[], has_categories: bool, has_tags: bool }
411 */
412 protected static function resolve_terms( array $args ) {
413 $result = array(
414 'categories' => array(),
415 'tags' => array(),
416 'has_categories' => array_key_exists( 'category_ids', $args ),
417 'has_tags' => array_key_exists( 'tag_ids', $args ),
418 );
419
420 if ( $result['has_categories'] ) {
421 $ids = Sanitizer::id_list( $args['category_ids'] );
422 foreach ( $ids as $id ) {
423 if ( ! term_exists( $id, CoursePostModel::TAXONOMY_CATEGORY ) ) {
424 return Errors::invalid(
425 sprintf(
426 /* translators: %d: term id. */
427 __( 'Invalid course category ID: %d.', 'learnpress' ),
428 $id
429 )
430 );
431 }
432 }
433 $result['categories'] = $ids;
434 }
435
436 if ( $result['has_tags'] ) {
437 $ids = Sanitizer::id_list( $args['tag_ids'] );
438 foreach ( $ids as $id ) {
439 if ( ! term_exists( $id, CoursePostModel::TAXONOMY_TAG ) ) {
440 return Errors::invalid(
441 sprintf(
442 /* translators: %d: term id. */
443 __( 'Invalid course tag ID: %d.', 'learnpress' ),
444 $id
445 )
446 );
447 }
448 }
449 $result['tags'] = $ids;
450 }
451
452 return $result;
453 }
454
455 /**
456 * Persist validated taxonomy relationships.
457 *
458 * @param int $course_id Course ID.
459 * @param array $terms Resolved terms.
460 *
461 * @return void
462 */
463 protected static function apply_terms( int $course_id, array $terms ): void {
464 if ( ! empty( $terms['has_categories'] ) ) {
465 wp_set_post_terms( $course_id, $terms['categories'], CoursePostModel::TAXONOMY_CATEGORY );
466 }
467 if ( ! empty( $terms['has_tags'] ) ) {
468 wp_set_post_terms( $course_id, $terms['tags'], CoursePostModel::TAXONOMY_TAG );
469 }
470 }
471
472 /**
473 * Validate an optional featured image attachment ID.
474 *
475 * @param array $args Input args.
476 *
477 * @return int|WP_Error 0 when not provided.
478 */
479 protected static function resolve_thumbnail( array $args ) {
480 if ( ! array_key_exists( 'featured_image_id', $args ) ) {
481 return 0;
482 }
483
484 $id = absint( $args['featured_image_id'] );
485 if ( $id <= 0 ) {
486 return 0;
487 }
488
489 if ( 'attachment' !== get_post_type( $id ) || ! wp_attachment_is_image( $id ) ) {
490 return Errors::invalid( __( 'featured_image_id must reference a valid image attachment.', 'learnpress' ) );
491 }
492
493 return $id;
494 }
495 }
496