PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.3
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.3
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 / Statistics / DashboardStatisticsDB.php

DashboardStatisticsDB.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.3, at inc/Statistics/DashboardStatisticsDB.php

1,804 lines 61.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class DashboardStatisticsDB
4 *
5 * @package LearnPress/Classes/Statistics
6 * @since 4.4.2
7 */
8
9 namespace LearnPress\Statistics;
10
11 use LP_Database;
12 use LP_Filter;
13 use LP_Statistics_DB;
14
15 defined( 'ABSPATH' ) || exit();
16
17 /**
18 * Aggregate queries for the statistics dashboard (Overview tab and beyond).
19 *
20 * All time-ranged methods share the signature
21 * ( string $type, string $value, ?StatisticsScope $scope = null ) and return
22 * typed empties on empty input — the REST controller try/catch is the only
23 * error boundary.
24 *
25 * @since 4.4.2
26 */
27 class DashboardStatisticsDB extends LP_Database {
28 /**
29 * @var DashboardStatisticsDB
30 */
31 private static $_instance;
32
33 protected function __construct() {
34 parent::__construct();
35 }
36
37 /**
38 * @return DashboardStatisticsDB
39 */
40 public static function getInstance(): DashboardStatisticsDB {
41 if ( is_null( self::$_instance ) ) {
42 self::$_instance = new self();
43 }
44
45 return self::$_instance;
46 }
47
48 /**
49 * Prepared "AND ..." time-range condition, reusing the tested
50 * LP_Statistics_DB::filter_time() mapping.
51 *
52 * @param string $type date|month|year|previous_days|previous_months|custom.
53 * @param string $value Time value.
54 * @param string $time_field Hardcoded column, e.g. 'ui.start_time'.
55 * @return string
56 */
57 private function time_condition( string $type, string $value, string $time_field ): string {
58 $filter = new LP_Filter();
59 $filter = LP_Statistics_DB::getInstance()->filter_time( $filter, $type, $time_field, $value );
60
61 return $filter->where[0] ?? '';
62 }
63
64 /**
65 * @param StatisticsScope|null $scope
66 * @param string $course_id_field
67 * @return string
68 */
69 private function scope_condition( ?StatisticsScope $scope, string $course_id_field ): string {
70 if ( ! $scope || $scope->is_empty() ) {
71 return '';
72 }
73
74 return $scope->sql_conditions( $course_id_field );
75 }
76
77 /**
78 * Prepared "AND {$field} LIKE '%...%'" fragment for the report popup search
79 * box. Empty search → no condition. LIKE wildcards in the term are escaped.
80 *
81 * The returned fragment is meant to be interpolated into ANOTHER
82 * wpdb::prepare() string (the report queries build their SQL that way). To
83 * survive that second prepare pass its literal '%' are doubled — the outer
84 * prepare() collapses each '%%' back to '%', reproducing the fragment
85 * verbatim instead of mistaking '%term%' for placeholders.
86 *
87 * @param string $search
88 * @param string $field Hardcoded, already-qualified column (e.g. 'p2.post_title').
89 * @return string
90 * @since 4.4.2
91 */
92 private function search_condition( string $search, string $field ): string {
93 $search = trim( $search );
94 if ( '' === $search ) {
95 return '';
96 }
97
98 $fragment = $this->wpdb->prepare( " AND {$field} LIKE %s", '%' . $this->wpdb->esc_like( $search ) . '%' );
99
100 return str_replace( '%', '%%', $fragment );
101 }
102
103 /**
104 * Course enrollments started in the range.
105 *
106 * @param string $type
107 * @param string $value
108 * @param StatisticsScope|null $scope
109 * @return int
110 */
111 public function get_enrollments_count( string $type, string $value, ?StatisticsScope $scope = null ): int {
112 if ( ! $type || ! $value ) {
113 return 0;
114 }
115
116 $time = $this->time_condition( $type, $value, 'ui.start_time' );
117 $where = $this->scope_condition( $scope, 'ui.item_id' );
118
119 $sql = $this->wpdb->prepare(
120 "SELECT COUNT(*) FROM {$this->tb_lp_user_items} AS ui
121 WHERE ui.item_type = %s {$time} {$where}",
122 LP_COURSE_CPT
123 );
124
125 return (int) $this->wpdb->get_var( $sql );
126 }
127
128 /**
129 * Per-course enrolled/completed rows for the range (one GROUP BY query).
130 *
131 * @param string $type
132 * @param string $value
133 * @param StatisticsScope|null $scope
134 * @return array Rows of { course_id, enrolled, completed }.
135 */
136 public function get_completion_rows( string $type, string $value, ?StatisticsScope $scope = null ): array {
137 if ( ! $type || ! $value ) {
138 return array();
139 }
140
141 $time = $this->time_condition( $type, $value, 'ui.start_time' );
142 $where = $this->scope_condition( $scope, 'ui.item_id' );
143
144 $sql = $this->wpdb->prepare(
145 "SELECT ui.item_id AS course_id,
146 COUNT(*) AS enrolled,
147 SUM( ui.status = %s ) AS completed
148 FROM {$this->tb_lp_user_items} AS ui
149 WHERE ui.item_type = %s {$time} {$where}
150 GROUP BY ui.item_id",
151 'finished',
152 LP_COURSE_CPT
153 );
154
155 $rows = $this->wpdb->get_results( $sql );
156
157 return is_array( $rows ) ? $rows : array();
158 }
159
160 /**
161 * Completion aggregate + per-course below-target count. Pure math on the
162 * grouped rows — kept static so unit tests need no DB.
163 *
164 * @param array $rows From get_completion_rows().
165 * @param int $target Completion target percent.
166 * @return array [ 'rate' => float|null, 'enrolled' => int, 'completed' => int, 'courses_below_target' => int ]
167 */
168 public static function completion_from_rows( array $rows, int $target ): array {
169 $enrolled = 0;
170 $completed = 0;
171 $below = 0;
172
173 foreach ( $rows as $row ) {
174 $course_enrolled = (int) ( $row->enrolled ?? 0 );
175 $course_completed = (int) ( $row->completed ?? 0 );
176 $enrolled += $course_enrolled;
177 $completed += $course_completed;
178
179 if ( $course_enrolled > 0 && ( $course_completed / $course_enrolled ) * 100 < $target ) {
180 ++$below;
181 }
182 }
183
184 return array(
185 'rate' => $enrolled > 0 ? round( $completed / $enrolled * 100, 1 ) : null,
186 'enrolled' => $enrolled,
187 'completed' => $completed,
188 'courses_below_target' => $below,
189 );
190 }
191
192 /**
193 * Average per-course completion rate for courses with at least one enrollment.
194 *
195 * Zero-enrollment courses are excluded because they have no denominator and
196 * would turn "no data yet" into a false 0% completion signal.
197 *
198 * @param array $rows From get_completion_rows().
199 * @return float|null
200 */
201 public static function average_completion_rate_from_rows( array $rows ): ?float {
202 $total_rate = 0.0;
203 $count = 0;
204
205 foreach ( $rows as $row ) {
206 $enrolled = (int) ( $row->enrolled ?? 0 );
207 if ( $enrolled <= 0 ) {
208 continue;
209 }
210
211 $total_rate += ( (int) ( $row->completed ?? 0 ) / $enrolled ) * 100;
212 ++$count;
213 }
214
215 return $count > 0 ? round( $total_rate / $count, 1 ) : null;
216 }
217
218 /**
219 * Completion stats for the range (target filterable).
220 *
221 * @param string $type
222 * @param string $value
223 * @param StatisticsScope|null $scope
224 * @return array See completion_from_rows().
225 */
226 public function get_completion_stats( string $type, string $value, ?StatisticsScope $scope = null ): array {
227 $target = (int) apply_filters( 'learn-press/statistics/completion-target', 70 );
228
229 return self::completion_from_rows( $this->get_completion_rows( $type, $value, $scope ), $target );
230 }
231
232 /**
233 * Distinct users who started a lesson or quiz in the range.
234 *
235 * Child rows carry their own start_time (verified on 4.4.x data);
236 * scope goes through the parent course user_item row (ui2).
237 *
238 * @param string $type
239 * @param string $value
240 * @param StatisticsScope|null $scope
241 * @return int
242 */
243 public function get_active_learners_count( string $type, string $value, ?StatisticsScope $scope = null ): int {
244 if ( ! $type || ! $value ) {
245 return 0;
246 }
247
248 $time = $this->time_condition( $type, $value, 'ui.start_time' );
249 $where = '';
250
251 if ( $scope && ! $scope->is_empty() ) {
252 $parent_conditions = $scope->sql_conditions( 'ui2.item_id' );
253 $where = " AND EXISTS ( SELECT 1 FROM {$this->tb_lp_user_items} AS ui2
254 WHERE ui2.user_item_id = ui.parent_id {$parent_conditions} )";
255 }
256
257 $sql = $this->wpdb->prepare(
258 "SELECT COUNT( DISTINCT ui.user_id ) FROM {$this->tb_lp_user_items} AS ui
259 WHERE ui.item_type IN ( %s, %s ) {$time} {$where}",
260 LP_LESSON_CPT,
261 LP_QUIZ_CPT
262 );
263
264 return (int) $this->wpdb->get_var( $sql );
265 }
266
267 /**
268 * Learner funnel counts for the range.
269 *
270 * 'registered' intentionally ignores scope: a user registration has no
271 * course dimension, so instructor/category cannot apply to it.
272 *
273 * @param string $type
274 * @param string $value
275 * @param StatisticsScope|null $scope
276 * @param bool $with_failed Add a 'failed' step (Users tab) — distinct users with a failed course graduation in range.
277 * @return array [ 'registered' => int, 'enrolled' => int, 'started' => int, 'completed' => int, 'failed'? => int ]
278 */
279 public function get_learner_funnel( string $type, string $value, ?StatisticsScope $scope = null, bool $with_failed = false ): array {
280 $sig = $scope ? $scope->signature() : array( 0, 0 );
281
282 return StatisticsCache::remember(
283 'funnel',
284 array( $type, $value, $sig, $with_failed ),
285 function () use ( $type, $value, $scope, $with_failed ) {
286 return $this->compute_learner_funnel( $type, $value, $scope, $with_failed );
287 }
288 );
289 }
290
291 /**
292 * Uncached funnel computation. See get_learner_funnel().
293 *
294 * @param string $type
295 * @param string $value
296 * @param StatisticsScope|null $scope
297 * @param bool $with_failed
298 * @return array
299 */
300 private function compute_learner_funnel( string $type, string $value, ?StatisticsScope $scope, bool $with_failed ): array {
301 if ( ! $type || ! $value ) {
302 $empty = array(
303 'registered' => 0,
304 'enrolled' => 0,
305 'started' => 0,
306 'completed' => 0,
307 );
308 if ( $with_failed ) {
309 $empty['failed'] = 0;
310 }
311
312 return $empty;
313 }
314
315 $course_scope = $this->scope_condition( $scope, 'ui.item_id' );
316
317 $time_users = $this->time_condition( $type, $value, 'u.user_registered' );
318 $registered = (int) $this->wpdb->get_var(
319 "SELECT COUNT(*) FROM {$this->tb_users} AS u WHERE 1=1 {$time_users}"
320 );
321
322 $time_items = $this->time_condition( $type, $value, 'ui.start_time' );
323
324 $enrolled = (int) $this->wpdb->get_var(
325 $this->wpdb->prepare(
326 "SELECT COUNT( DISTINCT ui.user_id ) FROM {$this->tb_lp_user_items} AS ui
327 WHERE ui.item_type = %s {$time_items} {$course_scope}",
328 LP_COURSE_CPT
329 )
330 );
331
332 $started = $this->get_active_learners_count( $type, $value, $scope );
333
334 $completed = (int) $this->wpdb->get_var(
335 $this->wpdb->prepare(
336 "SELECT COUNT( DISTINCT ui.user_id ) FROM {$this->tb_lp_user_items} AS ui
337 WHERE ui.item_type = %s AND ui.status = %s {$time_items} {$course_scope}",
338 LP_COURSE_CPT,
339 'finished'
340 )
341 );
342
343 $funnel = array(
344 'registered' => $registered,
345 'enrolled' => $enrolled,
346 'started' => $started,
347 'completed' => $completed,
348 );
349
350 if ( $with_failed ) {
351 $funnel['failed'] = (int) $this->wpdb->get_var(
352 $this->wpdb->prepare(
353 "SELECT COUNT( DISTINCT ui.user_id ) FROM {$this->tb_lp_user_items} AS ui
354 WHERE ui.item_type = %s AND ui.graduation = %s {$time_items} {$course_scope}",
355 LP_COURSE_CPT,
356 'failed'
357 )
358 );
359 }
360
361 return $funnel;
362 }
363
364 /**
365 * Revenue side of top-course performance (orders in range, grouped by course).
366 *
367 * @param string $type
368 * @param string $value
369 * @param StatisticsScope|null $scope
370 * @param int $limit Row cap (popup drill-down needs more than the widget's 50).
371 * @param string $search Optional course-title filter (popup search box).
372 * @return array Rows of { course_id, course_name, revenue, order_count }.
373 */
374 public function get_course_revenue_rows( string $type, string $value, ?StatisticsScope $scope = null, int $limit = 50, string $search = '' ): array {
375 if ( ! $type || ! $value ) {
376 return array();
377 }
378
379 $time = $this->time_condition( $type, $value, 'p.post_date' );
380 $where = $this->scope_condition( $scope, 'oi.item_id' );
381 $search = $this->search_condition( $search, 'p2.post_title' );
382
383 $sql = $this->wpdb->prepare(
384 "SELECT oi.item_id AS course_id,
385 p2.post_title AS course_name,
386 SUM( CAST( oim.meta_value AS DECIMAL(10,2) ) ) AS revenue,
387 COUNT( DISTINCT p.ID ) AS order_count
388 FROM {$this->tb_posts} AS p
389 INNER JOIN {$this->tb_lp_order_items} AS oi ON oi.order_id = p.ID
390 INNER JOIN {$this->tb_posts} AS p2 ON p2.ID = oi.item_id
391 INNER JOIN {$this->tb_lp_order_itemmeta} AS oim ON oim.learnpress_order_item_id = oi.order_item_id AND oim.meta_key = %s
392 WHERE p.post_type = %s AND p.post_status = %s AND oi.item_type = %s {$time} {$where} {$search}
393 GROUP BY oi.item_id, p2.post_title
394 ORDER BY revenue DESC
395 LIMIT %d",
396 '_total',
397 LP_ORDER_CPT,
398 LP_ORDER_COMPLETED_DB,
399 LP_COURSE_CPT,
400 max( 1, $limit )
401 );
402
403 $rows = $this->wpdb->get_results( $sql );
404
405 return is_array( $rows ) ? $rows : array();
406 }
407
408 /**
409 * Enrollment side of top-course performance (user_items in range, grouped by course).
410 *
411 * @param string $type
412 * @param string $value
413 * @param StatisticsScope|null $scope
414 * @param int $limit Row cap (popup drill-down needs more than the widget's 50).
415 * @param string $search Optional course-title filter (report popup search box).
416 * @return array Rows of { course_id, course_name, enrolled, completed }.
417 */
418 public function get_course_enrollment_rows( string $type, string $value, ?StatisticsScope $scope = null, int $limit = 50, string $search = '' ): array {
419 if ( ! $type || ! $value ) {
420 return array();
421 }
422
423 $time = $this->time_condition( $type, $value, 'ui.start_time' );
424 $where = $this->scope_condition( $scope, 'ui.item_id' );
425 $search = $this->search_condition( $search, 'p2.post_title' );
426
427 $sql = $this->wpdb->prepare(
428 "SELECT ui.item_id AS course_id,
429 p2.post_title AS course_name,
430 COUNT(*) AS enrolled,
431 SUM( ui.status = %s ) AS completed
432 FROM {$this->tb_lp_user_items} AS ui
433 INNER JOIN {$this->tb_posts} AS p2 ON p2.ID = ui.item_id
434 WHERE ui.item_type = %s {$time} {$where} {$search}
435 GROUP BY ui.item_id, p2.post_title
436 ORDER BY enrolled DESC
437 LIMIT %d",
438 'finished',
439 LP_COURSE_CPT,
440 max( 1, $limit )
441 );
442
443 $rows = $this->wpdb->get_results( $sql );
444
445 return is_array( $rows ) ? $rows : array();
446 }
447
448 /**
449 * Merge the two GROUP BY result sets by course_id (no mega-join — perf).
450 * Static pure math, unit-testable without DB.
451 *
452 * @param array $revenue_rows From get_course_revenue_rows().
453 * @param array $enroll_rows From get_course_enrollment_rows().
454 * @param int $limit Rows to keep after sorting by revenue, then enrollments.
455 * @return array Rows of { course_id, course_name, revenue, order_count, enrolled, completed, completion_rate }.
456 */
457 public static function merge_course_performance( array $revenue_rows, array $enroll_rows, int $limit ): array {
458 $merged = array();
459
460 foreach ( $revenue_rows as $row ) {
461 $course_id = (int) $row->course_id;
462 $merged[ $course_id ] = array(
463 'course_id' => $course_id,
464 'course_name' => (string) $row->course_name,
465 'revenue' => (float) $row->revenue,
466 'order_count' => (int) $row->order_count,
467 'enrolled' => 0,
468 'completed' => 0,
469 'completion_rate' => null,
470 );
471 }
472
473 foreach ( $enroll_rows as $row ) {
474 $course_id = (int) $row->course_id;
475
476 if ( ! isset( $merged[ $course_id ] ) ) {
477 $merged[ $course_id ] = array(
478 'course_id' => $course_id,
479 'course_name' => (string) $row->course_name,
480 'revenue' => 0.0,
481 'order_count' => 0,
482 'enrolled' => 0,
483 'completed' => 0,
484 'completion_rate' => null,
485 );
486 }
487
488 $enrolled = (int) $row->enrolled;
489 $completed = (int) $row->completed;
490
491 $merged[ $course_id ]['enrolled'] = $enrolled;
492 $merged[ $course_id ]['completed'] = $completed;
493 $merged[ $course_id ]['completion_rate'] = $enrolled > 0 ? round( $completed / $enrolled * 100, 1 ) : null;
494 }
495
496 usort(
497 $merged,
498 function ( $a, $b ) {
499 return array( $b['revenue'], $b['enrolled'] ) <=> array( $a['revenue'], $a['enrolled'] );
500 }
501 );
502
503 return array_slice( $merged, 0, max( 1, $limit ) );
504 }
505
506 /**
507 * Top courses by revenue + enrollments/completion for the range.
508 *
509 * @param string $type
510 * @param string $value
511 * @param StatisticsScope|null $scope
512 * @param int $limit
513 * @param string $search Optional course-title filter (report popup search box).
514 * @return array See merge_course_performance().
515 */
516 public function get_top_courses_performance( string $type, string $value, ?StatisticsScope $scope = null, int $limit = 5, string $search = '' ): array {
517 $fetch_limit = max( 50, $limit );
518
519 $merged = self::merge_course_performance(
520 $this->get_course_revenue_rows( $type, $value, $scope, $fetch_limit, $search ),
521 $this->get_course_enrollment_rows( $type, $value, $scope, $fetch_limit, $search ),
522 $limit
523 );
524
525 if ( empty( $merged ) ) {
526 return $merged;
527 }
528
529 // The revenue and enrollment lists are each capped independently, so a
530 // displayed course can arrive missing the OTHER dimension ( shown as 0 /
531 // null ). Re-query both metrics for exactly the displayed course ids so
532 // every row's revenue and enrollment/completion are exact.
533 $course_ids = array_map(
534 function ( $row ) {
535 return (int) $row['course_id'];
536 },
537 $merged
538 );
539 $revenue_map = $this->get_course_revenue_totals( $type, $value, $scope, $course_ids );
540 $enroll_map = $this->get_course_enrollment_totals( $type, $value, $scope, $course_ids );
541
542 foreach ( $merged as &$row ) {
543 $course_id = (int) $row['course_id'];
544
545 if ( isset( $revenue_map[ $course_id ] ) ) {
546 $row['revenue'] = $revenue_map[ $course_id ];
547 }
548
549 if ( isset( $enroll_map[ $course_id ] ) ) {
550 $enrolled = (int) $enroll_map[ $course_id ]['enrolled'];
551 $completed = (int) $enroll_map[ $course_id ]['completed'];
552 $row['enrolled'] = $enrolled;
553 $row['completed'] = $completed;
554 $row['completion_rate'] = $enrolled > 0 ? round( $completed / $enrolled * 100, 1 ) : null;
555 }
556 }
557 unset( $row );
558
559 return $merged;
560 }
561
562 /**
563 * Enrolled/completed course-row totals for a set of course IDs in the range.
564 * Companion to get_course_revenue_totals() — used to backfill the enrollment
565 * side of top-course performance for the displayed courses.
566 *
567 * @param string $type
568 * @param string $value
569 * @param StatisticsScope|null $scope
570 * @param array $course_ids
571 * @return array course_id => [ 'enrolled' => int, 'completed' => int ]
572 * @since 4.4.2
573 */
574 public function get_course_enrollment_totals( string $type, string $value, ?StatisticsScope $scope, array $course_ids ): array {
575 $course_ids = array_values( array_filter( array_unique( array_map( 'absint', $course_ids ) ) ) );
576 if ( ! $type || ! $value || empty( $course_ids ) ) {
577 return array();
578 }
579
580 $time = $this->time_condition( $type, $value, 'ui.start_time' );
581 $where = $this->scope_condition( $scope, 'ui.item_id' );
582 $placeholders = implode( ', ', array_fill( 0, count( $course_ids ), '%d' ) );
583
584 // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic %d list is built from absint-normalized IDs.
585 $sql = $this->wpdb->prepare(
586 "SELECT ui.item_id AS course_id,
587 COUNT(*) AS enrolled,
588 SUM( ui.status = %s ) AS completed
589 FROM {$this->tb_lp_user_items} AS ui
590 WHERE ui.item_type = %s AND ui.item_id IN ( {$placeholders} ) {$time} {$where}
591 GROUP BY ui.item_id",
592 'finished',
593 LP_COURSE_CPT,
594 ...$course_ids
595 );
596 // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
597
598 $rows = $this->wpdb->get_results( $sql );
599 $map = array();
600
601 foreach ( (array) $rows as $row ) {
602 $map[ (int) $row->course_id ] = array(
603 'enrolled' => (int) $row->enrolled,
604 'completed' => (int) $row->completed,
605 );
606 }
607
608 return $map;
609 }
610
611 /**
612 * Batch-map course IDs to their author's display name.
613 *
614 * @param array $course_ids
615 * @return array course_id => display_name
616 * @since 4.4.2
617 */
618 public function get_course_instructor_names( array $course_ids ): array {
619 $course_ids = array_values( array_filter( array_unique( array_map( 'absint', $course_ids ) ) ) );
620 if ( empty( $course_ids ) ) {
621 return array();
622 }
623
624 $placeholders = implode( ', ', array_fill( 0, count( $course_ids ), '%d' ) );
625 // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic %d list is built from absint-normalized IDs.
626 $sql = $this->wpdb->prepare(
627 "SELECT p.ID AS course_id, u.display_name AS instructor
628 FROM {$this->tb_posts} AS p
629 LEFT JOIN {$this->tb_users} AS u ON u.ID = p.post_author
630 WHERE p.ID IN ( {$placeholders} )",
631 ...$course_ids
632 );
633 // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
634 $rows = $this->wpdb->get_results( $sql );
635 $map = array();
636
637 foreach ( (array) $rows as $row ) {
638 $map[ (int) $row->course_id ] = (string) $row->instructor;
639 }
640
641 return $map;
642 }
643
644 /**
645 * Batch-map course IDs to their course-category names.
646 *
647 * Returns a list per course ( sorted by name ) so callers can show the
648 * primary category on screen while exporting the full set to CSV.
649 *
650 * @param array $course_ids
651 * @return array course_id => string[] category names
652 * @since 4.4.2
653 */
654 public function get_course_category_names( array $course_ids ): array {
655 $course_ids = array_values( array_filter( array_unique( array_map( 'absint', $course_ids ) ) ) );
656 if ( empty( $course_ids ) ) {
657 return array();
658 }
659
660 $placeholders = implode( ', ', array_fill( 0, count( $course_ids ), '%d' ) );
661 // Multi-char separator that will not occur inside a term name, so the
662 // GROUP_CONCAT can be split back into a clean list in PHP.
663 $sep = '|~|';
664 // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic %d list is built from absint-normalized IDs.
665 $sql = $this->wpdb->prepare(
666 "SELECT tr.object_id AS course_id,
667 GROUP_CONCAT( DISTINCT t.name ORDER BY t.name SEPARATOR '{$sep}' ) AS category
668 FROM {$this->wpdb->term_relationships} AS tr
669 INNER JOIN {$this->wpdb->term_taxonomy} AS tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = %s
670 INNER JOIN {$this->wpdb->terms} AS t ON t.term_id = tt.term_id
671 WHERE tr.object_id IN ( {$placeholders} )
672 GROUP BY tr.object_id",
673 LP_COURSE_CATEGORY_TAX,
674 ...$course_ids
675 );
676 // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
677 $rows = $this->wpdb->get_results( $sql );
678 $map = array();
679
680 foreach ( (array) $rows as $row ) {
681 // Term names are entity-encoded in the DB; the cell re-escapes, so decode here.
682 $names = explode( $sep, wp_specialchars_decode( (string) $row->category ) );
683 $names = array_values( array_filter( array_map( 'trim', $names ), 'strlen' ) );
684
685 $map[ (int) $row->course_id ] = $names;
686 }
687
688 return $map;
689 }
690
691 /**
692 * Completed-order revenue per course for a set of course IDs in the range.
693 * Used to compute the report "Trend" ( current vs previous period ).
694 *
695 * @param string $type
696 * @param string $value
697 * @param StatisticsScope|null $scope
698 * @param array $course_ids
699 * @return array course_id => float revenue
700 * @since 4.4.2
701 */
702 public function get_course_revenue_totals( string $type, string $value, ?StatisticsScope $scope, array $course_ids ): array {
703 $course_ids = array_values( array_filter( array_unique( array_map( 'absint', $course_ids ) ) ) );
704 if ( ! $type || ! $value || empty( $course_ids ) ) {
705 return array();
706 }
707
708 $time = $this->time_condition( $type, $value, 'p.post_date' );
709 $where = $this->scope_condition( $scope, 'oi.item_id' );
710 $placeholders = implode( ', ', array_fill( 0, count( $course_ids ), '%d' ) );
711
712 // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic %d list is built from absint-normalized IDs.
713 $sql = $this->wpdb->prepare(
714 "SELECT oi.item_id AS course_id,
715 SUM( CAST( oim.meta_value AS DECIMAL(10,2) ) ) AS revenue
716 FROM {$this->tb_posts} AS p
717 INNER JOIN {$this->tb_lp_order_items} AS oi ON oi.order_id = p.ID
718 INNER JOIN {$this->tb_lp_order_itemmeta} AS oim ON oim.learnpress_order_item_id = oi.order_item_id AND oim.meta_key = %s
719 WHERE p.post_type = %s AND p.post_status = %s AND oi.item_type = %s AND oi.item_id IN ( {$placeholders} ) {$time} {$where}
720 GROUP BY oi.item_id",
721 '_total',
722 LP_ORDER_CPT,
723 LP_ORDER_COMPLETED_DB,
724 LP_COURSE_CPT,
725 ...$course_ids
726 );
727 // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
728 $rows = $this->wpdb->get_results( $sql );
729 $map = array();
730
731 foreach ( (array) $rows as $row ) {
732 $map[ (int) $row->course_id ] = (float) $row->revenue;
733 }
734
735 return $map;
736 }
737
738 /**
739 * Current content inventory, not time-filtered.
740 *
741 * Course scope reaches courses directly through p.ID and curriculum items
742 * through section lineage (item -> section -> course).
743 *
744 * @param StatisticsScope|null $scope
745 * @return array
746 */
747 public function get_content_inventory( ?StatisticsScope $scope = null ): array {
748 $include_assignments = class_exists( 'LP_Assignment' );
749 $post_type_where = $include_assignments
750 ? $this->wpdb->prepare( 'p.post_type IN ( %s, %s, %s, %s )', LP_COURSE_CPT, LP_LESSON_CPT, LP_QUIZ_CPT, LP_ASSIGNMENT_CPT )
751 : $this->wpdb->prepare( 'p.post_type IN ( %s, %s, %s )', LP_COURSE_CPT, LP_LESSON_CPT, LP_QUIZ_CPT );
752 $item_type_where = $include_assignments
753 ? $this->wpdb->prepare( 'p.post_type IN ( %s, %s, %s )', LP_LESSON_CPT, LP_QUIZ_CPT, LP_ASSIGNMENT_CPT )
754 : $this->wpdb->prepare( 'p.post_type IN ( %s, %s )', LP_LESSON_CPT, LP_QUIZ_CPT );
755 $status_where = $this->wpdb->prepare( 'p.post_status IN ( %s, %s, %s, %s )', 'publish', 'pending', 'future', 'draft' );
756 $join = '';
757 $scope_where = '';
758
759 if ( $scope && ! $scope->is_empty() ) {
760 $join = "LEFT JOIN {$this->tb_lp_section_items} AS si ON si.item_id = p.ID
761 LEFT JOIN {$this->tb_lp_sections} AS s ON s.section_id = si.section_id";
762 $course_type_where = $this->wpdb->prepare( 'p.post_type = %s', LP_COURSE_CPT );
763 $course_scope = $scope->sql_conditions( 'p.ID' );
764 $item_scope = $scope->sql_conditions( 's.section_course_id' );
765 $scope_where = "AND ( ( {$course_type_where} {$course_scope} ) OR ( {$item_type_where} {$item_scope} ) )";
766 }
767
768 $sql = "SELECT p.post_type, p.post_status, COUNT( DISTINCT p.ID ) AS item_count
769 FROM {$this->tb_posts} AS p
770 {$join}
771 WHERE {$post_type_where} AND {$status_where} {$scope_where}
772 GROUP BY p.post_type, p.post_status";
773
774 $rows = $this->wpdb->get_results( $sql );
775
776 return self::content_inventory_from_rows( is_array( $rows ) ? $rows : array(), $include_assignments );
777 }
778
779 /**
780 * Fold GROUP BY post_type/status rows into the dashboard inventory shape.
781 *
782 * @param array $rows
783 * @param bool $include_assignments
784 * @return array
785 */
786 public static function content_inventory_from_rows( array $rows, bool $include_assignments ): array {
787 $statuses = array( 'publish', 'pending', 'future', 'draft' );
788 $assignment_post_type = defined( 'LP_ASSIGNMENT_CPT' ) ? LP_ASSIGNMENT_CPT : 'lp_assignment';
789 $bucket_map = array(
790 LP_COURSE_CPT => 'courses',
791 LP_LESSON_CPT => 'lessons',
792 LP_QUIZ_CPT => 'quizzes',
793 );
794
795 if ( $include_assignments ) {
796 $bucket_map[ $assignment_post_type ] = 'assignments';
797 }
798
799 $inventory = array();
800 foreach ( $bucket_map as $bucket ) {
801 $inventory[ $bucket ] = array_fill_keys( $statuses, 0 );
802 $inventory[ $bucket ]['total'] = 0;
803 }
804
805 foreach ( $rows as $row ) {
806 $post_type = (string) ( $row->post_type ?? '' );
807 $status = (string) ( $row->post_status ?? '' );
808
809 if ( ! isset( $bucket_map[ $post_type ] ) || ! in_array( $status, $statuses, true ) ) {
810 continue;
811 }
812
813 $count = (int) ( $row->item_count ?? 0 );
814 $bucket = $bucket_map[ $post_type ];
815
816 $inventory[ $bucket ][ $status ] = $count;
817 $inventory[ $bucket ]['total'] += $count;
818 }
819
820 return $inventory;
821 }
822
823 /**
824 * Paid course quantities sold in completed orders.
825 *
826 * @param string $type
827 * @param string $value
828 * @param StatisticsScope|null $scope
829 * @return int
830 */
831 public function get_paid_courses_sold( string $type, string $value, ?StatisticsScope $scope = null ): int {
832 if ( ! $type || ! $value ) {
833 return 0;
834 }
835
836 $time = $this->time_condition( $type, $value, 'p.post_date' );
837 $where = $this->scope_condition( $scope, 'oi.item_id' );
838
839 $sql = $this->wpdb->prepare(
840 "SELECT SUM( CAST( oim_qty.meta_value AS UNSIGNED ) )
841 FROM {$this->tb_posts} AS p
842 INNER JOIN {$this->tb_lp_order_items} AS oi ON oi.order_id = p.ID
843 INNER JOIN {$this->tb_lp_order_itemmeta} AS oim_qty ON oim_qty.learnpress_order_item_id = oi.order_item_id AND oim_qty.meta_key = %s
844 INNER JOIN {$this->tb_lp_order_itemmeta} AS oim_total ON oim_total.learnpress_order_item_id = oi.order_item_id AND oim_total.meta_key = %s AND CAST( oim_total.meta_value AS DECIMAL(10,2) ) > 0
845 WHERE p.post_type = %s AND p.post_status = %s AND oi.item_type = %s {$time} {$where}",
846 '_quantity',
847 '_total',
848 LP_ORDER_CPT,
849 LP_ORDER_COMPLETED_DB,
850 LP_COURSE_CPT
851 );
852
853 return (int) $this->wpdb->get_var( $sql );
854 }
855
856 /**
857 * Detailed paid top-sold courses for the Orders dashboard.
858 *
859 * @param string $type
860 * @param string $value
861 * @param StatisticsScope|null $scope
862 * @param int $limit
863 * @param int $offset Row offset for report-popup pagination.
864 * @param string $search Optional course-title filter.
865 * @return array Rows of { course_id, name, revenue, orders, aov, status_label }.
866 */
867 public function get_top_sold_courses_detailed( string $type, string $value, ?StatisticsScope $scope = null, int $limit = 10, int $offset = 0, string $search = '' ): array {
868 if ( ! $type || ! $value ) {
869 return array();
870 }
871
872 $limit = max( 1, $limit );
873 $offset = max( 0, $offset );
874 $time = $this->time_condition( $type, $value, 'p.post_date' );
875 $where = $this->scope_condition( $scope, 'oi.item_id' );
876 $search = $this->search_condition( $search, 'p2.post_title' );
877
878 $sql = $this->wpdb->prepare(
879 "SELECT oi.item_id AS course_id,
880 p2.post_title AS name,
881 SUM( CAST( oim_total.meta_value AS DECIMAL(10,2) ) ) AS revenue,
882 COUNT( DISTINCT p.ID ) AS orders
883 FROM {$this->tb_posts} AS p
884 INNER JOIN {$this->tb_lp_order_items} AS oi ON oi.order_id = p.ID
885 INNER JOIN {$this->tb_posts} AS p2 ON p2.ID = oi.item_id
886 INNER JOIN {$this->tb_lp_order_itemmeta} AS oim_total ON oim_total.learnpress_order_item_id = oi.order_item_id AND oim_total.meta_key = %s AND CAST( oim_total.meta_value AS DECIMAL(10,2) ) > 0
887 WHERE p.post_type = %s AND p.post_status = %s AND oi.item_type = %s {$time} {$where} {$search}
888 GROUP BY oi.item_id, p2.post_title
889 ORDER BY revenue DESC, orders DESC
890 LIMIT %d OFFSET %d",
891 '_total',
892 LP_ORDER_CPT,
893 LP_ORDER_COMPLETED_DB,
894 LP_COURSE_CPT,
895 $limit,
896 $offset
897 );
898
899 $rows = $this->wpdb->get_results( $sql );
900 if ( ! is_array( $rows ) ) {
901 return array();
902 }
903
904 $course_ids = array_map( 'absint', wp_list_pluck( $rows, 'course_id' ) );
905 $completion_map = $this->get_course_completion_rate_map( $type, $value, $course_ids );
906 $low_quiz_map = $this->get_low_quiz_course_map( $course_ids );
907 $completion_goal = (int) apply_filters( 'learn-press/statistics/completion-target', 70 );
908
909 return array_map(
910 function ( $row ) use ( $completion_map, $low_quiz_map, $completion_goal ) {
911 $course_id = (int) $row->course_id;
912 $revenue = (float) $row->revenue;
913 $orders = (int) $row->orders;
914 $completion_rate = $completion_map[ $course_id ] ?? null;
915
916 return array(
917 'course_id' => $course_id,
918 'name' => (string) $row->name,
919 'revenue' => $revenue,
920 'orders' => $orders,
921 'aov' => $orders > 0 ? round( $revenue / $orders, 2 ) : null,
922 'status_label' => self::course_status_label(
923 $completion_rate,
924 ! empty( $low_quiz_map[ $course_id ] ),
925 $completion_goal
926 ),
927 );
928 },
929 $rows
930 );
931 }
932
933 /**
934 * Total distinct paid courses matching get_top_sold_courses_detailed()'s
935 * filters — the row total for report-popup pagination.
936 *
937 * @param string $type
938 * @param string $value
939 * @param StatisticsScope|null $scope
940 * @param string $search
941 * @return int
942 * @since 4.4.2
943 */
944 public function count_top_sold_courses( string $type, string $value, ?StatisticsScope $scope = null, string $search = '' ): int {
945 if ( ! $type || ! $value ) {
946 return 0;
947 }
948
949 $time = $this->time_condition( $type, $value, 'p.post_date' );
950 $where = $this->scope_condition( $scope, 'oi.item_id' );
951 $search = $this->search_condition( $search, 'p2.post_title' );
952
953 $sql = $this->wpdb->prepare(
954 "SELECT COUNT(*) FROM (
955 SELECT oi.item_id
956 FROM {$this->tb_posts} AS p
957 INNER JOIN {$this->tb_lp_order_items} AS oi ON oi.order_id = p.ID
958 INNER JOIN {$this->tb_posts} AS p2 ON p2.ID = oi.item_id
959 INNER JOIN {$this->tb_lp_order_itemmeta} AS oim_total ON oim_total.learnpress_order_item_id = oi.order_item_id AND oim_total.meta_key = %s AND CAST( oim_total.meta_value AS DECIMAL(10,2) ) > 0
960 WHERE p.post_type = %s AND p.post_status = %s AND oi.item_type = %s {$time} {$where} {$search}
961 GROUP BY oi.item_id, p2.post_title
962 ) AS t",
963 '_total',
964 LP_ORDER_CPT,
965 LP_ORDER_COMPLETED_DB,
966 LP_COURSE_CPT
967 );
968
969 return (int) $this->wpdb->get_var( $sql );
970 }
971
972 /**
973 * Pure status-label mapping for Orders top-sold rows.
974 *
975 * @param float|null $completion_rate
976 * @param bool $has_low_quiz
977 * @param int $completion_goal
978 * @return string healthy|watch_completion|high_failed_quizzes.
979 */
980 public static function course_status_label( ?float $completion_rate, bool $has_low_quiz, int $completion_goal ): string {
981 if ( $has_low_quiz ) {
982 return 'high_failed_quizzes';
983 }
984
985 if ( null !== $completion_rate && $completion_rate < $completion_goal ) {
986 return 'watch_completion';
987 }
988
989 return 'healthy';
990 }
991
992 /**
993 * @param string $type
994 * @param string $value
995 * @param array $course_ids
996 * @return array course_id => completion_rate|null
997 */
998 private function get_course_completion_rate_map( string $type, string $value, array $course_ids ): array {
999 $course_ids = array_values( array_filter( array_unique( array_map( 'absint', $course_ids ) ) ) );
1000 if ( empty( $course_ids ) ) {
1001 return array();
1002 }
1003
1004 $placeholders = implode( ', ', array_fill( 0, count( $course_ids ), '%d' ) );
1005 $time = $this->time_condition( $type, $value, 'ui.start_time' );
1006
1007 // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic %d list is built from absint-normalized IDs.
1008 $sql = $this->wpdb->prepare(
1009 "SELECT ui.item_id AS course_id,
1010 COUNT(*) AS enrolled,
1011 SUM( ui.status = %s ) AS completed
1012 FROM {$this->tb_lp_user_items} AS ui
1013 WHERE ui.item_type = %s AND ui.item_id IN ( {$placeholders} ) {$time}
1014 GROUP BY ui.item_id",
1015 'finished',
1016 LP_COURSE_CPT,
1017 ...$course_ids
1018 );
1019 // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
1020
1021 $rows = $this->wpdb->get_results( $sql );
1022 $map = array();
1023
1024 foreach ( (array) $rows as $row ) {
1025 $enrolled = (int) $row->enrolled;
1026 $map[ (int) $row->course_id ] = $enrolled > 0 ? round( (int) $row->completed / $enrolled * 100, 1 ) : null;
1027 }
1028
1029 return $map;
1030 }
1031
1032 /**
1033 * @param array $course_ids
1034 * @return array course_id => true
1035 */
1036 private function get_low_quiz_course_map( array $course_ids ): array {
1037 $course_ids = array_values( array_filter( array_unique( array_map( 'absint', $course_ids ) ) ) );
1038 if ( empty( $course_ids ) ) {
1039 return array();
1040 }
1041
1042 list( $threshold, $min_attempts ) = self::quiz_alert_config();
1043 $placeholders = implode( ', ', array_fill( 0, count( $course_ids ), '%d' ) );
1044
1045 // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic %d list is built from absint-normalized IDs.
1046 $sql = $this->wpdb->prepare(
1047 "SELECT low_quizzes.course_id
1048 FROM (
1049 SELECT s.section_course_id AS course_id, ui.item_id
1050 FROM {$this->tb_lp_user_items} AS ui
1051 INNER JOIN {$this->tb_lp_section_items} AS si ON si.item_id = ui.item_id
1052 INNER JOIN {$this->tb_lp_sections} AS s ON s.section_id = si.section_id
1053 WHERE ui.item_type = %s AND ui.graduation IN ( %s, %s )
1054 GROUP BY s.section_course_id, ui.item_id
1055 HAVING COUNT(*) >= %d AND ( SUM( ui.graduation = %s ) / COUNT(*) ) * 100 < %f
1056 ) AS low_quizzes
1057 WHERE low_quizzes.course_id IN ( {$placeholders} )
1058 GROUP BY low_quizzes.course_id",
1059 LP_QUIZ_CPT,
1060 'passed',
1061 'failed',
1062 $min_attempts,
1063 'passed',
1064 $threshold,
1065 ...$course_ids
1066 );
1067 // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
1068
1069 $rows = $this->wpdb->get_col( $sql );
1070 $map = array();
1071
1072 foreach ( (array) $rows as $course_id ) {
1073 $map[ (int) $course_id ] = true;
1074 }
1075
1076 return $map;
1077 }
1078
1079 /**
1080 * Instructor summary: course count + range-bound revenue/enrollments/completion.
1081 *
1082 * Scope semantics (documented in the task file): the scope filters WHICH
1083 * instructors appear (instructor_id → that instructor; category_id →
1084 * instructors with a published course in the category). The per-row
1085 * metrics always cover the instructor's whole portfolio — an instructor
1086 * summary sliced to a category would misstate their performance.
1087 *
1088 * @param string $type
1089 * @param string $value
1090 * @param StatisticsScope|null $scope
1091 * @param int $limit
1092 * @param int $offset Row offset for report-popup pagination.
1093 * @param string $search Optional instructor-name filter.
1094 * @return array Rows of { instructor_id, instructor_name, course_count, revenue, enrolled, completed, completion_rate }.
1095 */
1096 public function get_instructor_performance( string $type, string $value, ?StatisticsScope $scope = null, int $limit = 5, int $offset = 0, string $search = '' ): array {
1097 $sig = $scope ? $scope->signature() : array( 0, 0 );
1098
1099 return StatisticsCache::remember(
1100 'instructor_performance',
1101 array( $type, $value, $sig, $limit, $offset, $search ),
1102 function () use ( $type, $value, $scope, $limit, $offset, $search ) {
1103 return $this->compute_instructor_performance( $type, $value, $scope, $limit, $offset, $search );
1104 }
1105 );
1106 }
1107
1108 /**
1109 * Uncached instructor performance query. See get_instructor_performance().
1110 *
1111 * @param string $type
1112 * @param string $value
1113 * @param StatisticsScope|null $scope
1114 * @param int $limit
1115 * @param int $offset
1116 * @param string $search
1117 * @return array
1118 */
1119 private function compute_instructor_performance( string $type, string $value, ?StatisticsScope $scope, int $limit, int $offset, string $search ): array {
1120 if ( ! $type || ! $value ) {
1121 return array();
1122 }
1123
1124 $offset = max( 0, $offset );
1125 $time_orders = $this->time_condition( $type, $value, 'o.post_date' );
1126 $time_items = $this->time_condition( $type, $value, 'ui.start_time' );
1127 $list_where = $this->instructor_list_where( $scope, $search );
1128
1129 // Revenue and enrollment are pre-aggregated per author in derived tables
1130 // ( one grouped pass each ) and LEFT JOINed, instead of three correlated
1131 // subqueries evaluated per instructor row. rev/enr hold at most one row per
1132 // author, so MAX() over the publish-course group returns that author's
1133 // value; COALESCE mirrors the old NULL→0 cast. Result is identical.
1134 $sql = $this->wpdb->prepare(
1135 "SELECT u.ID AS instructor_id,
1136 u.display_name AS instructor_name,
1137 COUNT( DISTINCT p.ID ) AS course_count,
1138 COALESCE( MAX( rev.revenue ), 0 ) AS revenue,
1139 COALESCE( MAX( enr.enrolled ), 0 ) AS enrolled,
1140 COALESCE( MAX( enr.completed ), 0 ) AS completed
1141 FROM {$this->tb_users} AS u
1142 INNER JOIN {$this->tb_posts} AS p ON p.post_author = u.ID AND p.post_type = %s AND p.post_status = 'publish'
1143 LEFT JOIN (
1144 SELECT pc.post_author AS author_id,
1145 SUM( CAST( oim.meta_value AS DECIMAL(10,2) ) ) AS revenue
1146 FROM {$this->tb_lp_order_items} AS oi
1147 INNER JOIN {$this->tb_posts} AS o ON o.ID = oi.order_id
1148 INNER JOIN {$this->tb_posts} AS pc ON pc.ID = oi.item_id
1149 INNER JOIN {$this->tb_lp_order_itemmeta} AS oim ON oim.learnpress_order_item_id = oi.order_item_id AND oim.meta_key = '_total'
1150 WHERE o.post_type = %s AND o.post_status = %s AND oi.item_type = %s {$time_orders}
1151 GROUP BY pc.post_author
1152 ) AS rev ON rev.author_id = u.ID
1153 LEFT JOIN (
1154 SELECT pc2.post_author AS author_id,
1155 COUNT(*) AS enrolled,
1156 SUM( ui.status = 'finished' ) AS completed
1157 FROM {$this->tb_lp_user_items} AS ui
1158 INNER JOIN {$this->tb_posts} AS pc2 ON pc2.ID = ui.item_id
1159 WHERE ui.item_type = %s {$time_items}
1160 GROUP BY pc2.post_author
1161 ) AS enr ON enr.author_id = u.ID
1162 WHERE 1=1 {$list_where}
1163 GROUP BY u.ID, u.display_name
1164 ORDER BY revenue DESC
1165 LIMIT %d OFFSET %d",
1166 LP_COURSE_CPT,
1167 LP_ORDER_CPT,
1168 LP_ORDER_COMPLETED_DB,
1169 LP_COURSE_CPT,
1170 LP_COURSE_CPT,
1171 max( 1, $limit ),
1172 $offset
1173 );
1174
1175 $rows = $this->wpdb->get_results( $sql );
1176 if ( ! is_array( $rows ) ) {
1177 return array();
1178 }
1179
1180 return array_map(
1181 function ( $row ) {
1182 $enrolled = (int) $row->enrolled;
1183 $completed = (int) $row->completed;
1184
1185 return array(
1186 'instructor_id' => (int) $row->instructor_id,
1187 'instructor_name' => (string) $row->instructor_name,
1188 'course_count' => (int) $row->course_count,
1189 'revenue' => (float) $row->revenue,
1190 'enrolled' => $enrolled,
1191 'completed' => $completed,
1192 'completion_rate' => $enrolled > 0 ? round( $completed / $enrolled * 100, 1 ) : null,
1193 );
1194 },
1195 $rows
1196 );
1197 }
1198
1199 /**
1200 * Shared WHERE fragment for the instructor list (scope + explicit instructor
1201 * + name search) so get_instructor_performance() and its count stay in sync.
1202 *
1203 * @param StatisticsScope|null $scope
1204 * @param string $search
1205 * @return string
1206 * @since 4.4.2
1207 */
1208 private function instructor_list_where( ?StatisticsScope $scope, string $search = '' ): string {
1209 $list_where = $this->scope_condition( $scope, 'p.ID' );
1210
1211 if ( $scope && $scope->instructor_id > 0 ) {
1212 $list_where .= $this->wpdb->prepare( ' AND u.ID = %d', $scope->instructor_id );
1213 }
1214
1215 $list_where .= $this->search_condition( $search, 'u.display_name' );
1216
1217 return $list_where;
1218 }
1219
1220 /**
1221 * Total instructors matching get_instructor_performance()'s filters — the
1222 * row total for report-popup pagination.
1223 *
1224 * @param string $type
1225 * @param string $value
1226 * @param StatisticsScope|null $scope
1227 * @param string $search
1228 * @return int
1229 * @since 4.4.2
1230 */
1231 public function count_instructor_performance( string $type, string $value, ?StatisticsScope $scope = null, string $search = '' ): int {
1232 if ( ! $type || ! $value ) {
1233 return 0;
1234 }
1235
1236 $list_where = $this->instructor_list_where( $scope, $search );
1237
1238 $sql = $this->wpdb->prepare(
1239 "SELECT COUNT( DISTINCT u.ID )
1240 FROM {$this->tb_users} AS u
1241 INNER JOIN {$this->tb_posts} AS p ON p.post_author = u.ID AND p.post_type = %s AND p.post_status = 'publish'
1242 WHERE 1=1 {$list_where}",
1243 LP_COURSE_CPT
1244 );
1245
1246 return (int) $this->wpdb->get_var( $sql );
1247 }
1248
1249 /**
1250 * Instructors whose courses received at least one enrollment in the range.
1251 *
1252 * @param string $type
1253 * @param string $value
1254 * @param StatisticsScope|null $scope
1255 * @return int
1256 */
1257 public function get_instructors_active_in_period( string $type, string $value, ?StatisticsScope $scope = null ): int {
1258 if ( ! $type || ! $value ) {
1259 return 0;
1260 }
1261
1262 $time = $this->time_condition( $type, $value, 'ui.start_time' );
1263 $where = $this->scope_condition( $scope, 'ui.item_id' );
1264
1265 $sql = $this->wpdb->prepare(
1266 "SELECT COUNT( DISTINCT p.post_author ) FROM {$this->tb_lp_user_items} AS ui
1267 INNER JOIN {$this->tb_posts} AS p ON p.ID = ui.item_id
1268 WHERE ui.item_type = %s {$time} {$where}",
1269 LP_COURSE_CPT
1270 );
1271
1272 return (int) $this->wpdb->get_var( $sql );
1273 }
1274
1275 /**
1276 * Distinct users with an in-progress course graduation in the range.
1277 *
1278 * @param string $type
1279 * @param string $value
1280 * @param StatisticsScope|null $scope
1281 * @return int
1282 */
1283 public function get_users_in_progress_count( string $type, string $value, ?StatisticsScope $scope = null ): int {
1284 if ( ! $type || ! $value ) {
1285 return 0;
1286 }
1287
1288 $time = $this->time_condition( $type, $value, 'ui.start_time' );
1289 $where = $this->scope_condition( $scope, 'ui.item_id' );
1290
1291 $sql = $this->wpdb->prepare(
1292 "SELECT COUNT( DISTINCT ui.user_id ) FROM {$this->tb_lp_user_items} AS ui
1293 WHERE ui.item_type = %s AND ui.graduation = %s {$time} {$where}",
1294 LP_COURSE_CPT,
1295 'in-progress'
1296 );
1297
1298 return (int) $this->wpdb->get_var( $sql );
1299 }
1300
1301 /**
1302 * Student status slug from activity + progress. Pure math, unit-testable.
1303 *
1304 * active — child-item activity within active_days;
1305 * at_risk — no recent activity and enrolled > ratio × completed;
1306 * idle — everything else.
1307 *
1308 * @param int $enrolled Courses enrolled in range.
1309 * @param int $completed Courses finished in range.
1310 * @param string|null $last_active MySQL datetime of last child activity, null = never started.
1311 * @param array $rules [ 'active_days' => int, 'at_risk_ratio' => float ].
1312 * @param string $now MySQL datetime anchor (injectable for tests).
1313 * @return string active|at_risk|idle
1314 */
1315 public static function student_status( int $enrolled, int $completed, ?string $last_active, array $rules, string $now ): string {
1316 $active_days = (int) ( $rules['active_days'] ?? 7 );
1317 $ratio = (float) ( $rules['at_risk_ratio'] ?? 2 );
1318
1319 if ( $last_active && strtotime( $last_active ) >= strtotime( $now ) - $active_days * DAY_IN_SECONDS ) {
1320 return 'active';
1321 }
1322
1323 if ( $enrolled > $ratio * $completed ) {
1324 return 'at_risk';
1325 }
1326
1327 return 'idle';
1328 }
1329
1330 /**
1331 * Top students by enrollments in the range.
1332 *
1333 * Two-phase for perf: one GROUP BY user_id over course rows picks the top
1334 * users, then last-activity and quiz pass-ratio are fetched in two batched
1335 * IN() queries for those users only — never per row, never JSON parsing
1336 * of user_item_results in SQL.
1337 *
1338 * Privacy: display_name only — this table is CSV-exported.
1339 *
1340 * @param string $type
1341 * @param string $value
1342 * @param StatisticsScope|null $scope
1343 * @param int $limit
1344 * @param int $offset Row offset for report-popup pagination.
1345 * @param string $search Optional student-name filter.
1346 * @return array Rows of { user_id, name, enrolled, completed, avg_score, last_active, status }.
1347 */
1348 public function get_top_students( string $type, string $value, ?StatisticsScope $scope = null, int $limit = 10, int $offset = 0, string $search = '' ): array {
1349 if ( ! $type || ! $value ) {
1350 return array();
1351 }
1352
1353 $offset = max( 0, $offset );
1354 $time = $this->time_condition( $type, $value, 'ui.start_time' );
1355 $where = $this->scope_condition( $scope, 'ui.item_id' );
1356 $search = $this->search_condition( $search, 'u.display_name' );
1357
1358 $rows = $this->wpdb->get_results(
1359 $this->wpdb->prepare(
1360 "SELECT ui.user_id,
1361 u.display_name AS name,
1362 COUNT(*) AS enrolled,
1363 SUM( ui.status = %s ) AS completed
1364 FROM {$this->tb_lp_user_items} AS ui
1365 INNER JOIN {$this->tb_users} AS u ON u.ID = ui.user_id
1366 WHERE ui.item_type = %s {$time} {$where} {$search}
1367 GROUP BY ui.user_id, u.display_name
1368 ORDER BY enrolled DESC, completed DESC
1369 LIMIT %d OFFSET %d",
1370 'finished',
1371 LP_COURSE_CPT,
1372 max( 1, $limit ),
1373 $offset
1374 )
1375 );
1376
1377 if ( ! is_array( $rows ) || ! $rows ) {
1378 return array();
1379 }
1380
1381 $user_ids = array_map(
1382 function ( $row ) {
1383 return (int) $row->user_id;
1384 },
1385 $rows
1386 );
1387 $placeholders = implode( ',', array_fill( 0, count( $user_ids ), '%d' ) );
1388
1389 $activity_rows = $this->wpdb->get_results(
1390 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- dynamic IN() placeholders, count matches at runtime.
1391 $this->wpdb->prepare(
1392 "SELECT c.user_id, MAX( c.start_time ) AS last_active
1393 FROM {$this->tb_lp_user_items} AS c
1394 WHERE c.item_type IN ( %s, %s ) AND c.user_id IN ( {$placeholders} )
1395 GROUP BY c.user_id",
1396 ...array_merge( array( LP_LESSON_CPT, LP_QUIZ_CPT ), $user_ids )
1397 )
1398 );
1399 $last_active = array_column( (array) $activity_rows, 'last_active', 'user_id' );
1400
1401 $score_rows = $this->wpdb->get_results(
1402 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- dynamic IN() placeholders, count matches at runtime.
1403 $this->wpdb->prepare(
1404 "SELECT q.user_id, ( SUM( q.graduation = %s ) / COUNT(*) ) * 100 AS avg_score
1405 FROM {$this->tb_lp_user_items} AS q
1406 WHERE q.item_type = %s AND q.graduation IN ( %s, %s ) AND q.user_id IN ( {$placeholders} )
1407 GROUP BY q.user_id",
1408 ...array_merge( array( 'passed', LP_QUIZ_CPT, 'passed', 'failed' ), $user_ids )
1409 )
1410 );
1411 $avg_score = array_column( (array) $score_rows, 'avg_score', 'user_id' );
1412
1413 $rules = apply_filters(
1414 'learn-press/statistics/student-status-rules',
1415 array(
1416 'active_days' => 7,
1417 'at_risk_ratio' => 2,
1418 )
1419 );
1420 $now = current_time( 'mysql' );
1421
1422 return array_map(
1423 function ( $row ) use ( $last_active, $avg_score, $rules, $now ) {
1424 $user_id = (int) $row->user_id;
1425 $active = $last_active[ $user_id ] ?? null;
1426
1427 return array(
1428 'user_id' => $user_id,
1429 'name' => (string) $row->name,
1430 'enrolled' => (int) $row->enrolled,
1431 'completed' => (int) $row->completed,
1432 'avg_score' => isset( $avg_score[ $user_id ] ) ? round( (float) $avg_score[ $user_id ], 1 ) : null,
1433 'last_active' => $active,
1434 'status' => self::student_status( (int) $row->enrolled, (int) $row->completed, $active, $rules, $now ),
1435 );
1436 },
1437 $rows
1438 );
1439 }
1440
1441 /**
1442 * Total distinct students matching get_top_students()'s filters — the row
1443 * total for report-popup pagination.
1444 *
1445 * @param string $type
1446 * @param string $value
1447 * @param StatisticsScope|null $scope
1448 * @param string $search
1449 * @return int
1450 * @since 4.4.2
1451 */
1452 public function count_top_students( string $type, string $value, ?StatisticsScope $scope = null, string $search = '' ): int {
1453 if ( ! $type || ! $value ) {
1454 return 0;
1455 }
1456
1457 $time = $this->time_condition( $type, $value, 'ui.start_time' );
1458 $where = $this->scope_condition( $scope, 'ui.item_id' );
1459 $search = $this->search_condition( $search, 'u.display_name' );
1460 $join = '' !== $search ? "INNER JOIN {$this->tb_users} AS u ON u.ID = ui.user_id" : '';
1461
1462 $sql = $this->wpdb->prepare(
1463 "SELECT COUNT( DISTINCT ui.user_id )
1464 FROM {$this->tb_lp_user_items} AS ui
1465 {$join}
1466 WHERE ui.item_type = %s {$time} {$where} {$search}",
1467 LP_COURSE_CPT
1468 );
1469
1470 return (int) $this->wpdb->get_var( $sql );
1471 }
1472
1473 /**
1474 * Courses ranked by students in the range, with started/active-7d
1475 * conditional sums — one GROUP BY, no per-row queries.
1476 *
1477 * @param string $type
1478 * @param string $value
1479 * @param StatisticsScope|null $scope
1480 * @param int $limit
1481 * @param int $offset Row offset for report-popup pagination.
1482 * @param string $search Optional course-title filter.
1483 * @return array Rows of { course_id, name, enrolled, started, completed, completion_rate, active_7d }.
1484 */
1485 public function get_courses_by_students( string $type, string $value, ?StatisticsScope $scope = null, int $limit = 10, int $offset = 0, string $search = '' ): array {
1486 if ( ! $type || ! $value ) {
1487 return array();
1488 }
1489
1490 $offset = max( 0, $offset );
1491 $time = $this->time_condition( $type, $value, 'ui.start_time' );
1492 $where = $this->scope_condition( $scope, 'ui.item_id' );
1493 $search = $this->search_condition( $search, 'p.post_title' );
1494 // active_in_period counts enrollments whose child activity ( lesson/quiz
1495 // start_time ) falls in the SELECTED period, not a fixed last-7-days window.
1496 $time_child = $this->time_condition( $type, $value, 'c.start_time' );
1497
1498 $rows = $this->wpdb->get_results(
1499 $this->wpdb->prepare(
1500 "SELECT ui.item_id AS course_id,
1501 p.post_title AS name,
1502 COUNT(*) AS enrolled,
1503 SUM( ui.status = %s ) AS completed,
1504 SUM( ch.pid IS NOT NULL ) AS started,
1505 SUM( ch.in_period = 1 ) AS active_in_period
1506 FROM {$this->tb_lp_user_items} AS ui
1507 INNER JOIN {$this->tb_posts} AS p ON p.ID = ui.item_id
1508 LEFT JOIN (
1509 SELECT c.parent_id AS pid,
1510 MAX( ( 0 = 0 {$time_child} ) ) AS in_period
1511 FROM {$this->tb_lp_user_items} AS c
1512 WHERE c.item_type IN ( %s, %s )
1513 GROUP BY c.parent_id
1514 ) AS ch ON ch.pid = ui.user_item_id
1515 WHERE ui.item_type = %s {$time} {$where} {$search}
1516 GROUP BY ui.item_id, p.post_title
1517 ORDER BY enrolled DESC
1518 LIMIT %d OFFSET %d",
1519 'finished',
1520 LP_LESSON_CPT,
1521 LP_QUIZ_CPT,
1522 LP_COURSE_CPT,
1523 max( 1, $limit ),
1524 $offset
1525 )
1526 );
1527
1528 if ( ! is_array( $rows ) ) {
1529 return array();
1530 }
1531
1532 return array_map(
1533 function ( $row ) {
1534 $enrolled = (int) $row->enrolled;
1535 $completed = (int) $row->completed;
1536
1537 return array(
1538 'course_id' => (int) $row->course_id,
1539 'name' => (string) $row->name,
1540 'enrolled' => $enrolled,
1541 'started' => (int) $row->started,
1542 'completed' => $completed,
1543 'completion_rate' => $enrolled > 0 ? round( $completed / $enrolled * 100, 1 ) : null,
1544 'active_in_period' => (int) $row->active_in_period,
1545 );
1546 },
1547 $rows
1548 );
1549 }
1550
1551 /**
1552 * Total distinct courses matching get_courses_by_students()'s filters — the
1553 * row total for report-popup pagination.
1554 *
1555 * @param string $type
1556 * @param string $value
1557 * @param StatisticsScope|null $scope
1558 * @param string $search
1559 * @return int
1560 * @since 4.4.2
1561 */
1562 public function count_courses_by_students( string $type, string $value, ?StatisticsScope $scope = null, string $search = '' ): int {
1563 if ( ! $type || ! $value ) {
1564 return 0;
1565 }
1566
1567 $time = $this->time_condition( $type, $value, 'ui.start_time' );
1568 $where = $this->scope_condition( $scope, 'ui.item_id' );
1569 $search = $this->search_condition( $search, 'p.post_title' );
1570 $join = '' !== $search ? "INNER JOIN {$this->tb_posts} AS p ON p.ID = ui.item_id" : '';
1571
1572 $sql = $this->wpdb->prepare(
1573 "SELECT COUNT( DISTINCT ui.item_id )
1574 FROM {$this->tb_lp_user_items} AS ui
1575 {$join}
1576 WHERE ui.item_type = %s {$time} {$where} {$search}",
1577 LP_COURSE_CPT
1578 );
1579
1580 return (int) $this->wpdb->get_var( $sql );
1581 }
1582
1583 /**
1584 * Map a course completion rate to a risk slug.
1585 *
1586 * Bands filterable via 'learn-press/statistics/risk-bands' — [ high_below, medium_ceiling ].
1587 * high: rate < high_below · medium: high_below ≤ rate ≤ medium_ceiling · healthy: above.
1588 * A null rate (no enrollments to assess) is treated as healthy — never a false alarm.
1589 *
1590 * @param float|null $completion_rate
1591 * @param array $bands [ 40, 55 ] by default.
1592 * @return string high|medium|healthy
1593 */
1594 public static function watchlist_risk( ?float $completion_rate, array $bands ): string {
1595 if ( null === $completion_rate ) {
1596 return 'healthy';
1597 }
1598
1599 $high = (float) ( $bands[0] ?? 40 );
1600 $medium = (float) ( $bands[1] ?? 55 );
1601
1602 if ( $completion_rate < $high ) {
1603 return 'high';
1604 }
1605
1606 if ( $completion_rate <= $medium ) {
1607 return 'medium';
1608 }
1609
1610 return 'healthy';
1611 }
1612
1613 /**
1614 * Recommended action slug for a watchlist row. Pure precedence, unit-testable.
1615 *
1616 * quiz failing → review_quiz_difficulty (most specific signal wins);
1617 * empty curriculum → build_curriculum (nothing else is actionable);
1618 * at-risk with curriculum → add_practice_content;
1619 * otherwise → monitor. Client maps slug → localized sentence.
1620 *
1621 * @param string $risk From watchlist_risk().
1622 * @param bool $has_curriculum
1623 * @param bool $has_low_quiz
1624 * @return string
1625 */
1626 public static function watchlist_action( string $risk, bool $has_curriculum, bool $has_low_quiz ): string {
1627 if ( $has_low_quiz ) {
1628 return 'review_quiz_difficulty';
1629 }
1630
1631 if ( ! $has_curriculum ) {
1632 return 'build_curriculum';
1633 }
1634
1635 if ( 'healthy' !== $risk ) {
1636 return 'add_practice_content';
1637 }
1638
1639 return 'monitor';
1640 }
1641
1642 /**
1643 * Per-course watchlist for the range: worst completion first, with risk +
1644 * recommended action. Only courses with at least one enrollment appear.
1645 *
1646 * @param string $type
1647 * @param string $value
1648 * @param StatisticsScope|null $scope
1649 * @param int $limit
1650 * @return array Rows of { course_id, name, instructor, completion_rate, risk, action }.
1651 */
1652 public function get_course_watchlist( string $type, string $value, ?StatisticsScope $scope = null, int $limit = 10 ): array {
1653 $sig = $scope ? $scope->signature() : array( 0, 0 );
1654
1655 return StatisticsCache::remember(
1656 'watchlist',
1657 // Risk bands + quiz-pass config shape risk/action, so they must vary the key —
1658 // otherwise a filter change stays masked until the TTL expires.
1659 array( $type, $value, $sig, $limit, self::quiz_alert_config(), (array) apply_filters( 'learn-press/statistics/risk-bands', array( 40, 55 ) ) ),
1660 function () use ( $type, $value, $scope, $limit ) {
1661 return $this->compute_course_watchlist( $type, $value, $scope, $limit );
1662 }
1663 );
1664 }
1665
1666 /**
1667 * Quiz low-pass alert config ( threshold %, minimum attempts ) — read in one
1668 * place so query methods and the cache keys that must vary on it stay in sync.
1669 *
1670 * @return array [ float threshold, int min_attempts ]
1671 * @since 4.4.2
1672 */
1673 private static function quiz_alert_config(): array {
1674 return array(
1675 (float) apply_filters( 'learn-press/statistics/quiz-pass-alert', 50 ),
1676 (int) apply_filters( 'learn-press/statistics/quiz-pass-alert-min-attempts', 5 ),
1677 );
1678 }
1679
1680 /**
1681 * Uncached watchlist query. See get_course_watchlist().
1682 *
1683 * @param string $type
1684 * @param string $value
1685 * @param StatisticsScope|null $scope
1686 * @param int $limit
1687 * @return array
1688 */
1689 private function compute_course_watchlist( string $type, string $value, ?StatisticsScope $scope, int $limit ): array {
1690 if ( ! $type || ! $value ) {
1691 return array();
1692 }
1693
1694 $time = $this->time_condition( $type, $value, 'ui.start_time' );
1695 $where = $this->scope_condition( $scope, 'ui.item_id' );
1696
1697 $rows = $this->wpdb->get_results(
1698 $this->wpdb->prepare(
1699 "SELECT ui.item_id AS course_id,
1700 p.post_title AS name,
1701 u.display_name AS instructor,
1702 COUNT(*) AS enrolled,
1703 SUM( ui.status = %s ) AS completed,
1704 EXISTS (
1705 SELECT 1 FROM {$this->tb_lp_sections} AS s
1706 INNER JOIN {$this->tb_lp_section_items} AS si ON si.section_id = s.section_id
1707 WHERE s.section_course_id = ui.item_id
1708 ) AS has_curriculum
1709 FROM {$this->tb_lp_user_items} AS ui
1710 INNER JOIN {$this->tb_posts} AS p ON p.ID = ui.item_id
1711 INNER JOIN {$this->tb_users} AS u ON u.ID = p.post_author
1712 WHERE ui.item_type = %s {$time} {$where}
1713 GROUP BY ui.item_id, p.post_title, u.display_name
1714 ORDER BY ( SUM( ui.status = %s ) / COUNT(*) ) ASC, enrolled DESC
1715 LIMIT %d",
1716 'finished',
1717 LP_COURSE_CPT,
1718 'finished',
1719 max( 1, $limit )
1720 )
1721 );
1722
1723 if ( ! is_array( $rows ) || ! $rows ) {
1724 return array();
1725 }
1726
1727 $course_ids = array_map(
1728 function ( $row ) {
1729 return (int) $row->course_id;
1730 },
1731 $rows
1732 );
1733 $low_quiz_map = $this->get_low_quiz_course_map( $course_ids );
1734 $bands = (array) apply_filters( 'learn-press/statistics/risk-bands', array( 40, 55 ) );
1735
1736 return array_map(
1737 function ( $row ) use ( $low_quiz_map, $bands ) {
1738 $enrolled = (int) $row->enrolled;
1739 $completion_rate = $enrolled > 0 ? round( (int) $row->completed / $enrolled * 100, 1 ) : null;
1740 $has_curriculum = (bool) $row->has_curriculum;
1741 $has_low_quiz = ! empty( $low_quiz_map[ (int) $row->course_id ] );
1742 $risk = self::watchlist_risk( $completion_rate, $bands );
1743 $action = self::watchlist_action( $risk, $has_curriculum, $has_low_quiz );
1744
1745 return array(
1746 'course_id' => (int) $row->course_id,
1747 'name' => (string) $row->name,
1748 'instructor' => (string) $row->instructor,
1749 'completion_rate' => $completion_rate,
1750 'risk' => $risk,
1751 // Per-row override point for gateway/add-on rules.
1752 'action' => (string) apply_filters( 'learn-press/statistics/watchlist-actions', $action, $row, $risk ),
1753 );
1754 },
1755 $rows
1756 );
1757 }
1758
1759 /**
1760 * Pending-review course counts per instructor (all-time — a pending course
1761 * is a backlog regardless of the selected range). Scope-filtered.
1762 *
1763 * @param StatisticsScope|null $scope
1764 * @return array Rows of { instructor_id, name, pending }, most pending first.
1765 */
1766 public function get_pending_courses_by_instructor( ?StatisticsScope $scope = null ): array {
1767 $where = $this->scope_condition( $scope, 'p.ID' );
1768
1769 if ( $scope && $scope->instructor_id > 0 ) {
1770 $where .= $this->wpdb->prepare( ' AND u.ID = %d', $scope->instructor_id );
1771 }
1772
1773 $rows = $this->wpdb->get_results(
1774 $this->wpdb->prepare(
1775 "SELECT p.post_author AS instructor_id,
1776 u.display_name AS name,
1777 COUNT(*) AS pending
1778 FROM {$this->tb_posts} AS p
1779 INNER JOIN {$this->tb_users} AS u ON u.ID = p.post_author
1780 WHERE p.post_type = %s AND p.post_status = %s {$where}
1781 GROUP BY p.post_author, u.display_name
1782 ORDER BY pending DESC",
1783 LP_COURSE_CPT,
1784 'pending'
1785 )
1786 );
1787
1788 if ( ! is_array( $rows ) ) {
1789 return array();
1790 }
1791
1792 return array_map(
1793 function ( $row ) {
1794 return array(
1795 'instructor_id' => (int) $row->instructor_id,
1796 'name' => (string) $row->name,
1797 'pending' => (int) $row->pending,
1798 );
1799 },
1800 $rows
1801 );
1802 }
1803 }
1804