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 / Statistics / DashboardStatisticsDB.php

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

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