PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.4
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.4
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 / Webhook / WebhookResourceSerializer.php

WebhookResourceSerializer.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.4, at inc/Webhook/WebhookResourceSerializer.php

953 lines 28.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace LearnPress\Webhook;
4
5 use LearnPress\Models\CourseModel;
6 use LearnPress\Models\UserItems\UserCourseModel;
7 use LearnPress\Models\UserItems\UserItemModel;
8 use LearnPress\Models\UserItems\UserQuizModel;
9 use LP_User_Items_DB;
10 use LP_User_Items_Filter;
11 use LP_User_Items_Result_DB;
12 use Throwable;
13
14 defined( 'ABSPATH' ) || exit;
15
16 /**
17 * Builds versioned, enriched webhook payload data while keeping legacy fields intact.
18 */
19 class WebhookResourceSerializer {
20 const API_VERSION = 'v1';
21
22 /**
23 * @var self|null
24 */
25 protected static $instance;
26
27 /**
28 * @var array<int, array<string, mixed>>
29 */
30 protected $post_snapshots = array();
31
32 /**
33 * @var array<int, array<string, mixed>>
34 */
35 protected $user_snapshots = array();
36
37 /**
38 * @var array<int, array<string, mixed>>
39 */
40 protected $order_snapshots = array();
41
42 /**
43 * Get singleton instance.
44 *
45 * @return self
46 */
47 public static function instance(): self {
48 if ( ! self::$instance ) {
49 self::$instance = new self();
50 }
51
52 return self::$instance;
53 }
54
55 /**
56 * Build the final webhook envelope before filters and HMAC signing.
57 *
58 * @param string $event_key Event key.
59 * @param string $delivery_id Delivery ID.
60 * @param array $data Legacy event data.
61 *
62 * @return array<string, mixed>
63 */
64 public function build_payload( string $event_key, string $delivery_id, array $data ): array {
65 return array(
66 'id' => $delivery_id,
67 'event' => $event_key,
68 'api_version' => self::API_VERSION,
69 'created_at' => gmdate( 'c' ),
70 'site_url' => home_url( '/' ),
71 'data' => $this->enrich_data( $event_key, $data ),
72 );
73 }
74
75 /**
76 * Enrich legacy data with human-readable names and compact resource snapshots.
77 *
78 * @param string $event_key Event key.
79 * @param array $data Legacy event data.
80 *
81 * @return array<string, mixed>
82 */
83 public function enrich_data( string $event_key, array $data ): array {
84 unset( $event_key );
85
86 $data = $this->enrich_primary_ids( $data );
87 $data = $this->enrich_order_data( $data );
88 $data = $this->enrich_user_item_data( $data );
89 $data = $this->enrich_announcement_data( $data );
90 $data = $this->enrich_membership_data( $data );
91 $data = $this->enrich_instructor_data( $data );
92
93 return $data;
94 }
95
96 /**
97 * Add common resource aliases for top-level *_id fields.
98 *
99 * @param array $data Payload data.
100 *
101 * @return array<string, mixed>
102 */
103 protected function enrich_primary_ids( array $data ): array {
104 if ( array_key_exists( 'user_id', $data ) ) {
105 $this->add_user_aliases( $data, $data['user_id'] );
106 }
107
108 if ( array_key_exists( 'course_id', $data ) ) {
109 $this->add_post_aliases( $data, 'course', $data['course_id'], $this->course_post_type() );
110 }
111
112 if ( array_key_exists( 'course_ids', $data ) && is_array( $data['course_ids'] ) ) {
113 $course_ids = $this->normalize_ids( $data['course_ids'] );
114 $courses = array();
115 $names = array();
116
117 foreach ( $course_ids as $course_id ) {
118 $course = $this->get_post_snapshot( $course_id, $this->course_post_type() );
119 if ( $course['name'] !== '' ) {
120 $names[] = $course['name'];
121 }
122 $courses[] = $course;
123 }
124
125 $this->set_if_missing( $data, 'course_names', $names );
126 $this->set_if_missing( $data, 'courses', $courses );
127 }
128
129 if ( array_key_exists( 'item_id', $data ) ) {
130 $this->add_post_aliases( $data, 'item', $data['item_id'], (string) ( $data['item_type'] ?? '' ) );
131 }
132
133 if ( array_key_exists( 'ref_id', $data ) ) {
134 $ref_type = (string) ( $data['ref_type'] ?? '' );
135 if ( $this->is_order_type( $ref_type ) ) {
136 $ref = $this->get_order_snapshot( absint( $data['ref_id'] ) );
137 $this->set_if_missing( $data, 'ref_name', $ref['title'] );
138 $this->set_if_missing( $data, 'ref', $ref );
139 } else {
140 $this->add_post_aliases( $data, 'ref', $data['ref_id'], $ref_type );
141 }
142 }
143
144 if ( array_key_exists( 'assignment_id', $data ) ) {
145 $this->add_post_aliases( $data, 'assignment', $data['assignment_id'], 'lp_assignment' );
146 }
147
148 if ( array_key_exists( 'announcement_id', $data ) ) {
149 $this->add_post_aliases( $data, 'announcement', $data['announcement_id'], 'lp_announcement' );
150 }
151
152 if ( array_key_exists( 'plan_id', $data ) ) {
153 $this->add_post_aliases( $data, 'plan', $data['plan_id'], '' );
154 }
155
156 if ( array_key_exists( 'order_id', $data ) ) {
157 $order = $this->get_order_snapshot( absint( $data['order_id'] ) );
158 $this->set_if_missing( $data, 'order_number', $order['number'] );
159 $this->set_if_missing( $data, 'order_title', $order['title'] );
160 $this->set_if_missing( $data, 'order', $order );
161 }
162
163 if ( array_key_exists( 'renew_order_id', $data ) ) {
164 $renew_order = $this->get_order_snapshot( absint( $data['renew_order_id'] ) );
165 $this->set_if_missing( $data, 'renew_order_number', $renew_order['number'] );
166 $this->set_if_missing( $data, 'renew_order', $renew_order );
167 }
168
169 if ( array_key_exists( 'author_id', $data ) ) {
170 $user = $this->get_user_snapshot( absint( $data['author_id'] ) );
171 $this->set_if_missing( $data, 'author_name', $user['name'] );
172 $this->set_if_missing( $data, 'author', $user );
173 }
174
175 return $data;
176 }
177
178 /**
179 * Add order item names and order/customer snapshots.
180 *
181 * @param array $data Payload data.
182 *
183 * @return array<string, mixed>
184 */
185 protected function enrich_order_data( array $data ): array {
186 if ( isset( $data['items'] ) && is_array( $data['items'] ) ) {
187 $order_item_names = array();
188
189 foreach ( $data['items'] as $index => $item ) {
190 if ( ! is_array( $item ) ) {
191 continue;
192 }
193
194 $item_id = absint( $item['item_id'] ?? 0 );
195 $item_type = (string) ( $item['item_type'] ?? '' );
196 $snapshot = $this->get_post_snapshot( $item_id, $item_type );
197 $name = (string) ( $item['order_item_name'] ?? '' );
198 if ( '' === $name ) {
199 $name = $snapshot['name'];
200 }
201
202 $this->set_if_missing( $item, 'order_item_name', $name );
203 $this->set_if_missing( $item, 'item_name', $snapshot['name'] );
204 $this->set_if_missing( $item, 'item', $snapshot );
205
206 if ( '' !== $name ) {
207 $order_item_names[] = $name;
208 }
209
210 $data['items'][ $index ] = $item;
211 }
212
213 $this->set_if_missing( $data, 'order_item_names', $order_item_names );
214 }
215
216 if ( isset( $data['order'] ) && is_array( $data['order'] ) ) {
217 $order = $data['order'];
218
219 if ( isset( $order['users'] ) && is_array( $order['users'] ) ) {
220 foreach ( $order['users'] as $index => $user ) {
221 if ( ! is_array( $user ) ) {
222 continue;
223 }
224
225 $user_id = absint( $user['id'] ?? $user['user_id'] ?? 0 );
226 $snapshot = $this->get_user_snapshot( $user_id );
227 $this->set_if_missing( $user, 'name', $snapshot['name'] );
228 $this->set_if_missing( $user, 'display_name', $snapshot['display_name'] );
229 $order['users'][ $index ] = $user;
230 }
231 }
232
233 if ( isset( $order['customer'] ) && is_array( $order['customer'] ) ) {
234 $customer = $order['customer'];
235 $user_id = absint( $customer['id'] ?? $customer['user_id'] ?? 0 );
236 if ( $user_id > 0 ) {
237 $snapshot = $this->get_user_snapshot( $user_id );
238 $this->set_if_missing( $customer, 'name', $snapshot['name'] );
239 $this->set_if_missing( $customer, 'display_name', $snapshot['display_name'] );
240 }
241 $order['customer'] = $customer;
242 }
243
244 $data['order'] = $order;
245 }
246
247 return $data;
248 }
249
250 /**
251 * Add user item names, snapshots, result, and course progress when applicable.
252 *
253 * @param array $data Payload data.
254 *
255 * @return array<string, mixed>
256 */
257 protected function enrich_user_item_data( array $data ): array {
258 if ( ! isset( $data['user_item'] ) || ! is_array( $data['user_item'] ) ) {
259 return $data;
260 }
261
262 $user_item = $data['user_item'];
263 $user_id = absint( $user_item['user_id'] ?? $data['user_id'] ?? 0 );
264 $item_id = absint( $user_item['item_id'] ?? $data['item_id'] ?? 0 );
265 $item_type = (string) ( $user_item['item_type'] ?? $data['item_type'] ?? '' );
266 $ref_id = absint( $user_item['ref_id'] ?? $data['ref_id'] ?? 0 );
267 $ref_type = (string) ( $user_item['ref_type'] ?? $data['ref_type'] ?? '' );
268 $course_id = absint( $data['course_id'] ?? $user_item['course_id'] ?? 0 );
269
270 if ( ! $course_id ) {
271 if ( $item_type === $this->course_post_type() ) {
272 $course_id = $item_id;
273 } elseif ( $ref_type === $this->course_post_type() ) {
274 $course_id = $ref_id;
275 }
276 }
277
278 if ( $course_id > 0 ) {
279 $this->set_if_missing( $data, 'course_id', $course_id );
280 $this->set_if_missing( $user_item, 'course_id', $course_id );
281 }
282
283 $user_snapshot = $this->get_user_snapshot( $user_id );
284 $item_snapshot = $this->get_post_snapshot( $item_id, $item_type );
285 $course_snapshot = $this->get_post_snapshot( $course_id, $this->course_post_type() );
286 $ref_snapshot = $this->is_order_type( $ref_type ) ? $this->get_order_snapshot( $ref_id ) : $this->get_post_snapshot( $ref_id, $ref_type );
287
288 $this->set_if_missing( $data, 'user_name', $user_snapshot['name'] );
289 $this->set_if_missing( $data, 'item_name', $item_snapshot['name'] );
290 $this->set_if_missing( $data, 'course_name', $course_snapshot['name'] );
291 $this->set_if_missing( $data, 'ref_name', (string) ( $ref_snapshot['name'] ?? $ref_snapshot['title'] ?? '' ) );
292
293 $this->set_if_missing( $user_item, 'user_name', $user_snapshot['name'] );
294 $this->set_if_missing( $user_item, 'item_name', $item_snapshot['name'] );
295 $this->set_if_missing( $user_item, 'course_name', $course_snapshot['name'] );
296 $this->set_if_missing( $user_item, 'ref_name', (string) ( $ref_snapshot['name'] ?? $ref_snapshot['title'] ?? '' ) );
297 $this->set_if_missing( $user_item, 'learning_status', sanitize_key( (string) ( $user_item['status'] ?? '' ) ) );
298 $this->set_if_missing( $user_item, 'started_at', $this->sanitize_text( (string) ( $user_item['start_time'] ?? '' ) ) );
299 $this->set_if_missing( $user_item, 'ended_at', $this->sanitize_text( (string) ( $user_item['end_time'] ?? '' ) ) );
300 $this->set_if_missing( $user_item, 'duration_seconds', $this->duration_seconds( (string) ( $user_item['start_time'] ?? '' ), (string) ( $user_item['end_time'] ?? '' ) ) );
301 $this->set_if_missing( $user_item, 'user', $user_snapshot );
302 $this->set_if_missing( $user_item, 'item', $item_snapshot );
303 $this->set_if_missing( $user_item, 'course', $course_snapshot );
304 $this->set_if_missing( $user_item, 'ref', $ref_snapshot );
305 if ( isset( $user_item['result'] ) && is_array( $user_item['result'] ) ) {
306 $user_item['result'] = $this->strip_sensitive_result_fields( $user_item['result'] );
307 } else {
308 $user_item['result'] = $this->get_user_item_result( $user_item );
309 }
310
311 if ( $item_type === $this->course_post_type() && $course_id > 0 ) {
312 $this->set_if_missing( $user_item, 'progress', $this->get_course_progress( $user_id, $course_id ) );
313 }
314
315 $data['user_item'] = $user_item;
316
317 return $data;
318 }
319
320 /**
321 * Add announcement names and course names.
322 *
323 * @param array $data Payload data.
324 *
325 * @return array<string, mixed>
326 */
327 protected function enrich_announcement_data( array $data ): array {
328 if ( ! isset( $data['announcement_id'] ) ) {
329 return $data;
330 }
331
332 $announcement = $this->get_post_snapshot( absint( $data['announcement_id'] ), 'lp_announcement' );
333 $this->set_if_missing( $data, 'announcement_name', $announcement['name'] );
334 $this->set_if_missing( $data, 'announcement', $announcement );
335
336 return $data;
337 }
338
339 /**
340 * Add membership aliases from IDs and safe gateway data.
341 *
342 * @param array $data Payload data.
343 *
344 * @return array<string, mixed>
345 */
346 protected function enrich_membership_data( array $data ): array {
347 if ( isset( $data['webhook_data'] ) && is_array( $data['webhook_data'] ) ) {
348 $member_status = $data['webhook_data']['member_status'] ?? $data['webhook_data']['status'] ?? null;
349 if ( null !== $member_status ) {
350 $this->set_if_missing( $data, 'member_status', sanitize_key( (string) $member_status ) );
351 }
352
353 if ( isset( $data['webhook_data']['course_ids'] ) && is_array( $data['webhook_data']['course_ids'] ) ) {
354 $this->set_if_missing( $data, 'course_ids', $data['webhook_data']['course_ids'] );
355 $data = $this->enrich_primary_ids( $data );
356 }
357 }
358
359 return $data;
360 }
361
362 /**
363 * Add user_name to instructor events when a user or request name is available.
364 *
365 * @param array $data Payload data.
366 *
367 * @return array<string, mixed>
368 */
369 protected function enrich_instructor_data( array $data ): array {
370 if ( isset( $data['user_name'] ) ) {
371 return $data;
372 }
373
374 if ( ! empty( $data['user_id'] ) ) {
375 $user = $this->get_user_snapshot( absint( $data['user_id'] ) );
376 $this->set_if_missing( $data, 'user_name', $user['name'] );
377 } elseif ( ! empty( $data['name'] ) ) {
378 $this->set_if_missing( $data, 'user_name', $this->sanitize_text( (string) $data['name'] ) );
379 }
380
381 return $data;
382 }
383
384 /**
385 * Add user aliases to payload data.
386 *
387 * @param array $data Payload data by reference.
388 * @param mixed $user_id User ID or list of IDs.
389 *
390 * @return void
391 */
392 protected function add_user_aliases( array &$data, $user_id ): void {
393 if ( is_array( $user_id ) ) {
394 $users = array();
395 $names = array();
396 foreach ( $this->normalize_ids( $user_id ) as $id ) {
397 $user = $this->get_user_snapshot( $id );
398 $users[] = $user;
399 if ( '' !== $user['name'] ) {
400 $names[] = $user['name'];
401 }
402 }
403
404 $this->set_if_missing( $data, 'users', $users );
405 $this->set_if_missing( $data, 'user_names', $names );
406 return;
407 }
408
409 $user = $this->get_user_snapshot( absint( $user_id ) );
410 $this->set_if_missing( $data, 'user_name', $user['name'] );
411 $this->set_if_missing( $data, 'user', $user );
412 }
413
414 /**
415 * Add post-like resource aliases to payload data.
416 *
417 * @param array $data Payload data by reference.
418 * @param string $prefix Alias prefix.
419 * @param mixed $post_id Post ID.
420 * @param string $post_type Post type.
421 *
422 * @return void
423 */
424 protected function add_post_aliases( array &$data, string $prefix, $post_id, string $post_type = '' ): void {
425 $post_id = absint( $post_id );
426 $post = $this->get_post_snapshot( $post_id, $post_type );
427
428 $this->set_if_missing( $data, "{$prefix}_name", $post['name'] );
429 $this->set_if_missing( $data, $prefix, $post );
430 }
431
432 /**
433 * Return a compact user snapshot.
434 *
435 * @param int $user_id User ID.
436 *
437 * @return array<string, mixed>
438 */
439 protected function get_user_snapshot( int $user_id ): array {
440 if ( isset( $this->user_snapshots[ $user_id ] ) ) {
441 return $this->user_snapshots[ $user_id ];
442 }
443
444 $name = '';
445 $user = false;
446
447 $user = $user_id > 0 ? get_userdata( $user_id ) : false;
448
449 if ( is_object( $user ) ) {
450 $name = $this->sanitize_text( (string) ( $user->display_name ?? $user->user_login ?? '' ) );
451 }
452
453 $snapshot = array(
454 'id' => $user_id,
455 'type' => 'user',
456 'name' => $name,
457 'display_name' => $name,
458 );
459
460 $this->user_snapshots[ $user_id ] = $snapshot;
461
462 return $snapshot;
463 }
464
465 /**
466 * Return a compact post/resource snapshot.
467 *
468 * @param int $post_id Post ID.
469 * @param string $post_type Post type hint.
470 *
471 * @return array<string, mixed>
472 */
473 protected function get_post_snapshot( int $post_id, string $post_type = '' ): array {
474 if ( $post_id <= 0 ) {
475 return array(
476 'id' => 0,
477 'type' => $post_type,
478 'name' => '',
479 'title' => '',
480 'slug' => '',
481 'permalink' => '',
482 );
483 }
484
485 $key = "{$post_id}:{$post_type}";
486 if ( isset( $this->post_snapshots[ $key ] ) ) {
487 return $this->post_snapshots[ $key ];
488 }
489
490 $post = get_post( $post_id );
491 $type = $post_type;
492 if ( is_object( $post ) && ! empty( $post->post_type ) ) {
493 $type = (string) $post->post_type;
494 } elseif ( '' === $type ) {
495 $type = (string) get_post_type( $post_id );
496 }
497
498 $title = is_object( $post ) && isset( $post->post_title ) ? $this->sanitize_text( (string) $post->post_title ) : '';
499 $slug = is_object( $post ) && isset( $post->post_name ) ? $this->sanitize_key_string( (string) $post->post_name ) : '';
500 $permalink = get_permalink( $post_id );
501 $link = is_string( $permalink ) ? $permalink : '';
502
503 $snapshot = array(
504 'id' => $post_id,
505 'type' => $this->sanitize_key_string( $type ),
506 'name' => $title,
507 'title' => $title,
508 'slug' => $slug,
509 'permalink' => esc_url_raw( $link ),
510 );
511
512 $this->post_snapshots[ $key ] = $snapshot;
513
514 return $snapshot;
515 }
516
517 /**
518 * Return a compact order snapshot.
519 *
520 * @param int $order_id Order ID.
521 *
522 * @return array<string, mixed>
523 */
524 protected function get_order_snapshot( int $order_id ): array {
525 if ( isset( $this->order_snapshots[ $order_id ] ) ) {
526 return $this->order_snapshots[ $order_id ];
527 }
528
529 $order = $order_id > 0 ? learn_press_get_order( $order_id ) : null;
530 $post = $this->get_post_snapshot( $order_id, 'lp_order' );
531 $number = (string) $order_id;
532 $title = $post['title'];
533 $status = '';
534
535 if ( is_object( $order ) ) {
536 $number = $this->sanitize_text( (string) $this->call_method( $order, 'get_order_number', $number ) );
537 $title = $this->sanitize_text( (string) $this->call_method( $order, 'get_title', $title ) );
538 $status = $this->sanitize_key_string( (string) $this->call_method( $order, 'get_status', '' ) );
539 }
540
541 $snapshot = array_merge(
542 $post,
543 array(
544 'type' => '' !== $post['type'] ? $post['type'] : 'lp_order',
545 'number' => $number,
546 'title' => $title,
547 'name' => $title,
548 'status' => $status,
549 )
550 );
551
552 $this->order_snapshots[ $order_id ] = $snapshot;
553
554 return $snapshot;
555 }
556
557 /**
558 * Return safe result data for a user item.
559 *
560 * @param array $user_item User item data.
561 *
562 * @return array<string, mixed>
563 */
564 protected function get_user_item_result( array $user_item ): array {
565 $user_item_id = absint( $user_item['user_item_id'] ?? 0 );
566 $item_type = (string) ( $user_item['item_type'] ?? '' );
567 $result = array();
568
569 if ( $item_type === $this->course_post_type() ) {
570 $user_course = $this->get_user_course_model( absint( $user_item['user_id'] ?? 0 ), absint( $user_item['item_id'] ?? 0 ) );
571 if ( is_object( $user_course ) && method_exists( $user_course, 'calculate_course_results' ) ) {
572 $result = $this->call_method( $user_course, 'calculate_course_results', array() );
573 }
574 } elseif ( $item_type === $this->quiz_post_type() ) {
575 $quiz = $this->get_user_item_model( $user_item );
576 if ( $quiz instanceof UserQuizModel ) {
577 $result = $this->call_method( $quiz, 'get_result', array() );
578 }
579 }
580
581 if ( empty( $result ) && $user_item_id > 0 ) {
582 $result_from_db = LP_User_Items_Result_DB::instance()->get_result( $user_item_id );
583 $result = is_array( $result_from_db ) ? $result_from_db : array();
584 }
585
586 return is_array( $result ) ? $this->strip_sensitive_result_fields( $result ) : array();
587 }
588
589 /**
590 * Return full course progress with safe curriculum item data.
591 *
592 * @param int $user_id User ID.
593 * @param int $course_id Course ID.
594 *
595 * @return array<string, mixed>
596 */
597 protected function get_course_progress( int $user_id, int $course_id ): array {
598 $progress = array(
599 'sections' => array(),
600 );
601
602 $course = $this->get_course_model( $course_id );
603 if ( ! is_object( $course ) ) {
604 return $progress;
605 }
606
607 $user_course = $this->get_user_course_model( $user_id, $course_id );
608 $user_items = $this->get_course_user_items_map( $user_course, $user_id );
609 $sections = $this->call_method( $course, 'get_section_items', array() );
610 if ( empty( $sections ) ) {
611 $sections = $this->call_method( $course, 'get_full_sections_and_items_course', array() );
612 }
613 if ( ! is_array( $sections ) ) {
614 return $progress;
615 }
616
617 foreach ( $sections as $section ) {
618 $section_items = is_object( $section ) && isset( $section->items ) && is_array( $section->items ) ? $section->items : array();
619 $items = array();
620
621 foreach ( $section_items as $item ) {
622 $item_id = absint( $item->item_id ?? $item->id ?? 0 );
623 $item_type = (string) ( $item->item_type ?? $item->type ?? '' );
624 $item_snapshot = $this->get_post_snapshot( $item_id, $item_type );
625 $item_user_item = array();
626 $attend = $user_items[ $item_id ] ?? null;
627
628 if ( ! is_object( $attend ) && is_object( $user_course ) && method_exists( $user_course, 'get_item_attend' ) ) {
629 $attend = $this->call_method( $user_course, 'get_item_attend', false, array( $item_id, $item_type ) );
630 }
631
632 if ( is_object( $attend ) ) {
633 $item_user_item = $this->normalize_user_item_object( $attend );
634 }
635
636 $item_result = ! empty( $item_user_item ) ? $this->get_user_item_result( $item_user_item ) : array();
637 $status = sanitize_key( (string) ( $item_user_item['status'] ?? 'not-started' ) );
638
639 $items[] = array(
640 'id' => $item_id,
641 'type' => $item_snapshot['type'],
642 'name' => $item_snapshot['name'],
643 'title' => $item_snapshot['title'],
644 'status' => $status,
645 'learning_status' => $status,
646 'user_item' => $item_user_item,
647 'result' => $item_result,
648 );
649 }
650
651 $section_name = $this->sanitize_text( (string) ( $section->section_name ?? $section->title ?? '' ) );
652 $progress['sections'][] = array(
653 'id' => absint( $section->section_id ?? $section->id ?? 0 ),
654 'section_id' => absint( $section->section_id ?? $section->id ?? 0 ),
655 'name' => $section_name,
656 'title' => $section_name,
657 'order' => absint( $section->section_order ?? $section->order ?? 0 ),
658 'description' => $this->sanitize_text( (string) ( $section->section_description ?? $section->description ?? '' ) ),
659 'items' => $items,
660 );
661 }
662
663 return $progress;
664 }
665
666 /**
667 * Load child user items of a course in one DB call when possible.
668 *
669 * @param object|false $user_course User course model.
670 * @param int $user_id User ID.
671 *
672 * @return array<int, object>
673 */
674 protected function get_course_user_items_map( $user_course, int $user_id ): array {
675 if ( ! is_object( $user_course ) ) {
676 return array();
677 }
678
679 $parent_id = method_exists( $user_course, 'get_user_item_id' ) ? absint( $this->call_method( $user_course, 'get_user_item_id', 0 ) ) : 0;
680 if ( $parent_id <= 0 ) {
681 return array();
682 }
683
684 try {
685 $filter = new LP_User_Items_Filter();
686 $filter->user_id = $user_id;
687 $filter->parent_id = $parent_id;
688 $items = LP_User_Items_DB::getInstance()->get_user_course_items( $filter );
689 } catch ( Throwable $e ) {
690 return array();
691 }
692
693 if ( ! is_array( $items ) ) {
694 return array();
695 }
696
697 $map = array();
698 foreach ( $items as $item ) {
699 if ( is_object( $item ) && isset( $item->item_id ) ) {
700 $map[ absint( $item->item_id ) ] = $item;
701 }
702 }
703
704 return $map;
705 }
706
707 /**
708 * Normalize a user item object into webhook-safe data.
709 *
710 * @param object $user_item User item object.
711 *
712 * @return array<string, mixed>
713 */
714 protected function normalize_user_item_object( $user_item ): array {
715 return array(
716 'user_item_id' => is_object( $user_item ) && method_exists( $user_item, 'get_user_item_id' ) ? absint( $this->call_method( $user_item, 'get_user_item_id', 0 ) ) : 0,
717 'user_id' => absint( $user_item->user_id ?? 0 ),
718 'item_id' => absint( $user_item->item_id ?? 0 ),
719 'item_type' => sanitize_key( (string) ( $user_item->item_type ?? '' ) ),
720 'course_id' => absint( $user_item->ref_id ?? 0 ),
721 'ref_id' => absint( $user_item->ref_id ?? 0 ),
722 'ref_type' => sanitize_key( (string) ( $user_item->ref_type ?? '' ) ),
723 'status' => sanitize_key( (string) ( $user_item->status ?? '' ) ),
724 'learning_status' => sanitize_key( (string) ( $user_item->status ?? '' ) ),
725 'graduation' => sanitize_key( (string) ( $user_item->graduation ?? '' ) ),
726 'started_at' => $this->sanitize_text( (string) ( $user_item->start_time ?? '' ) ),
727 'ended_at' => $this->sanitize_text( (string) ( $user_item->end_time ?? '' ) ),
728 'duration_seconds' => $this->duration_seconds( (string) ( $user_item->start_time ?? '' ), (string) ( $user_item->end_time ?? '' ) ),
729 );
730 }
731
732 /**
733 * Resolve a concrete user item model when useful for result methods.
734 *
735 * @param array $user_item User item data.
736 *
737 * @return object|false
738 */
739 protected function get_user_item_model( array $user_item ) {
740 try {
741 $model = UserItemModel::find_user_item(
742 absint( $user_item['user_id'] ?? 0 ),
743 absint( $user_item['item_id'] ?? 0 ),
744 (string) ( $user_item['item_type'] ?? '' ),
745 absint( $user_item['ref_id'] ?? $user_item['course_id'] ?? 0 ),
746 (string) ( $user_item['ref_type'] ?? $this->course_post_type() ),
747 true
748 );
749
750 if ( $model && (string) ( $user_item['item_type'] ?? '' ) === $this->quiz_post_type() ) {
751 return new UserQuizModel( $model );
752 }
753
754 return $model;
755 } catch ( Throwable $e ) {
756 return false;
757 }
758 }
759
760 /**
761 * Resolve a course model.
762 *
763 * @param int $course_id Course ID.
764 *
765 * @return object|false
766 */
767 protected function get_course_model( int $course_id ) {
768 if ( $course_id <= 0 ) {
769 return false;
770 }
771 $course = CourseModel::find( $course_id, true );
772 if ( $course instanceof CourseModel ) {
773 return $course;
774 } else {
775 return false;
776 }
777 }
778
779 /**
780 * Resolve a user course model.
781 *
782 * @param int $user_id User ID.
783 * @param int $course_id Course ID.
784 *
785 * @return object|false
786 */
787 protected function get_user_course_model( int $user_id, int $course_id ) {
788 return UserCourseModel::find( $user_id, $course_id, true );
789 }
790
791 /**
792 * Remove sensitive or oversized result fields from quiz/assignment data.
793 *
794 * @param array $result Result data.
795 *
796 * @return array<string, mixed>
797 */
798 protected function strip_sensitive_result_fields( array $result ): array {
799 $blocked = array(
800 'answers',
801 'answer',
802 'answered',
803 'answer_data',
804 'questions',
805 'question_answers',
806 'files',
807 'file',
808 'attachments',
809 'submission',
810 'submissions',
811 'notes',
812 );
813
814 foreach ( $blocked as $key ) {
815 unset( $result[ $key ] );
816 }
817
818 foreach ( $result as $key => $value ) {
819 if ( is_array( $value ) ) {
820 $result[ $key ] = $this->strip_sensitive_result_fields( $value );
821 }
822 }
823
824 return $result;
825 }
826
827 /**
828 * Set a field only when the legacy payload did not already provide a value.
829 *
830 * @param array $data Payload data by reference.
831 * @param string $key Field key.
832 * @param mixed $value Value.
833 *
834 * @return void
835 */
836 protected function set_if_missing( array &$data, string $key, $value ): void {
837 if ( ! array_key_exists( $key, $data ) || null === $data[ $key ] || '' === $data[ $key ] || array() === $data[ $key ] ) {
838 $data[ $key ] = $value;
839 }
840 }
841
842 /**
843 * Normalize a scalar/list of IDs.
844 *
845 * @param array $ids Raw IDs.
846 *
847 * @return int[]
848 */
849 protected function normalize_ids( array $ids ): array {
850 $normalized = array();
851 foreach ( $ids as $id ) {
852 $id = absint( $id );
853 if ( $id > 0 ) {
854 $normalized[] = $id;
855 }
856 }
857
858 return array_values( array_unique( $normalized ) );
859 }
860
861 /**
862 * Call an object method when it is available.
863 *
864 * @param object $target Target object.
865 * @param string $method Method name.
866 * @param mixed $fallback Fallback value.
867 * @param array $args Method arguments.
868 *
869 * @return mixed
870 */
871 protected function call_method( $target, string $method, $fallback = null, array $args = array() ) {
872 if ( ! is_object( $target ) || ! method_exists( $target, $method ) ) {
873 return $fallback;
874 }
875
876 return $target->{$method}( ...$args );
877 }
878
879 /**
880 * Calculate duration between two datetime strings.
881 *
882 * @param string $start Start time.
883 * @param string $end End time.
884 *
885 * @return int|null
886 */
887 protected function duration_seconds( string $start, string $end ) {
888 if ( '' === $start || '' === $end ) {
889 return null;
890 }
891
892 $start_timestamp = strtotime( $start );
893 $end_timestamp = strtotime( $end );
894
895 if ( ! $start_timestamp || ! $end_timestamp || $end_timestamp < $start_timestamp ) {
896 return null;
897 }
898
899 return $end_timestamp - $start_timestamp;
900 }
901
902 /**
903 * Return LearnPress course post type.
904 *
905 * @return string
906 */
907 protected function course_post_type(): string {
908 return defined( 'LP_COURSE_CPT' ) ? LP_COURSE_CPT : 'lp_course';
909 }
910
911 /**
912 * Return LearnPress quiz post type.
913 *
914 * @return string
915 */
916 protected function quiz_post_type(): string {
917 return defined( 'LP_QUIZ_CPT' ) ? LP_QUIZ_CPT : 'lp_quiz';
918 }
919
920 /**
921 * Check whether a resource type represents an order.
922 *
923 * @param string $type Resource type.
924 *
925 * @return bool
926 */
927 protected function is_order_type( string $type ): bool {
928 return in_array( $type, array( 'lp_order', 'learnpress_order', 'order' ), true );
929 }
930
931 /**
932 * Sanitize display text.
933 *
934 * @param string $value Raw text.
935 *
936 * @return string
937 */
938 protected function sanitize_text( string $value ): string {
939 return sanitize_text_field( $value );
940 }
941
942 /**
943 * Sanitize a key-like value.
944 *
945 * @param string $value Raw key.
946 *
947 * @return string
948 */
949 protected function sanitize_key_string( string $value ): string {
950 return sanitize_key( $value );
951 }
952 }
953