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 / Webhook / WebhookDispatcher.php

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

1,455 lines 45.0 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\Webhook\WebhookModel;
6 use Throwable;
7
8 defined( 'ABSPATH' ) || exit;
9
10 /**
11 * Dispatch outbound LearnPress webhook events.
12 */
13 class WebhookDispatcher {
14 /**
15 * @var self|null
16 */
17 protected static $instance;
18
19 /**
20 * Delivery keys already processed in the current request.
21 *
22 * @var array<string, bool>
23 */
24 protected $processed = array();
25
26 /**
27 * Get singleton instance.
28 *
29 * @return self
30 */
31 public static function instance(): self {
32 if ( ! self::$instance ) {
33 self::$instance = new self();
34 }
35
36 return self::$instance;
37 }
38
39 /**
40 * Register LearnPress event hooks.
41 */
42 protected function __construct() {
43 add_action( 'learnpress/user/course-enrolled', array( $this, 'handle_course_enrolled' ), 10, 3 );
44 add_action( 'learn-press/order/created', array( $this, 'handle_order_created' ), 10, 2 );
45 add_action( 'learn-press/order/status-changed', array( $this, 'handle_order_status_changed' ), 10, 3 );
46 add_action( 'learn-press/checkout-order-processed', array( $this, 'handle_checkout_order_processed' ), 10, 2 );
47 add_action( 'learn_press_course_submit_rejected', array( $this, 'handle_course_submit_rejected' ), 10, 10 );
48 add_action( 'learn_press_course_submit_approved', array( $this, 'handle_course_submit_approved' ), 10, 10 );
49 add_action( 'learn_press_course_submit_for_reviewer', array( $this, 'handle_course_submit_for_review' ), 10, 10 );
50 add_action( 'learn-press/user-lesson/completed', array( $this, 'handle_lesson_completed' ) );
51 add_action( 'learn-press/user/quiz/started', array( $this, 'handle_quiz_started' ) );
52 add_action( 'learn-press/user/quiz-finished', array( $this, 'handle_quiz_finished' ), 10, 4 );
53 add_action( 'learn-press/user/quiz/retake', array( $this, 'handle_quiz_retried' ) );
54 add_action( 'learn-press/user-course/finished', array( $this, 'handle_course_finished' ) );
55 add_action( 'learn-press/user-item/created', array( $this, 'handle_user_item_created' ) );
56 add_filter( 'retrieve_password_notification_email', array( $this, 'handle_password_reset_requested' ), 12, 4 );
57 add_action( 'learn-press/become-a-teacher-sent', array( $this, 'handle_instructor_requested' ) );
58 add_action( 'learn-press/user-become-a-teacher-accept', array( $this, 'handle_instructor_accepted' ) );
59 add_action( 'learn-press/user-become-a-teacher-deny', array( $this, 'handle_instructor_denied' ) );
60 add_action( 'learnpress/membership/order-completed', array( $this, 'handle_membership_order_completed' ), 10, 3 );
61 add_action( 'learnpress/membership/subscription/renewed', array( $this, 'handle_membership_subscription_renewed' ), 10, 6 );
62 add_action( 'learnpress/membership/subscription/cancelled', array( $this, 'handle_membership_subscription_cancelled' ), 10, 5 );
63 add_action( 'learnpress/membership/subscription/suspended', array( $this, 'handle_membership_subscription_suspended' ), 10, 5 );
64 add_action( 'learnpress/membership/subscription/expired', array( $this, 'handle_membership_subscription_expired' ), 10, 5 );
65 add_action( 'learnpress/membership/subscription/payment-failed', array( $this, 'handle_membership_payment_failed' ), 10, 7 );
66 add_action( 'learnpress/membership/reminder/expiring', array( $this, 'handle_membership_reminder_expiring' ) );
67 add_action( 'learnpress/membership/course-resumed', array( $this, 'handle_membership_course_resumed' ), 10, 3 );
68 add_action( 'learn-press/assignment/user/start', array( $this, 'handle_assignment_started' ) );
69 add_action( 'learn-press/assignment/student-start-assignment', array( $this, 'handle_assignment_started_legacy' ), 10, 4 );
70 add_action( 'learn-press/assignment/student-submitted', array( $this, 'handle_assignment_submitted' ), 10, 2 );
71 add_action( 'learn-press/assignment/instructor-evaluated', array( $this, 'handle_assignment_evaluated' ), 10, 2 );
72 add_action( 'learn-press/instructor-evaluated-assignment', array( $this, 'handle_assignment_evaluated_legacy' ), 10, 2 );
73 add_action( 'learn-press/assignment/instructor-re-evaluated', array( $this, 'handle_assignment_re_evaluated' ), 10, 2 );
74 add_action( 'learn-press/instructor-re-evaluated-assignment', array( $this, 'handle_assignment_re_evaluated_legacy' ), 10, 2 );
75 add_action( 'learn-press/assignment/user/retake', array( $this, 'handle_assignment_retried' ) );
76 add_action( 'learn-press/assignment/student-retake-assignment', array( $this, 'handle_assignment_retried_legacy' ), 10, 4 );
77 add_action( 'learn-press/announcement/created', array( $this, 'handle_announcement_created' ), 10, 3 );
78 add_action( 'learn-press/announcement/email-queued', array( $this, 'handle_announcement_email_queued' ), 10, 2 );
79 }
80
81 /**
82 * Dispatch enrollment events.
83 *
84 * @param int $order_id Order ID.
85 * @param int $course_id Course ID.
86 * @param int $user_id User ID.
87 *
88 * @return void
89 */
90 public function handle_course_enrolled( $order_id, $course_id, $user_id ): void {
91 $data = array(
92 'order_id' => absint( $order_id ),
93 'course_id' => absint( $course_id ),
94 'user_id' => absint( $user_id ),
95 );
96 $key = "{$data['order_id']}:{$data['course_id']}:{$data['user_id']}";
97
98 $this->dispatch( 'user.course_enrolled', $data, $key );
99 $this->dispatch( 'course.enrolled', $data, $key );
100 }
101
102 /**
103 * Dispatch order created event.
104 *
105 * @param int $order_id Order ID.
106 * @param \LP_Order $order Order object.
107 *
108 * @return void
109 */
110 public function handle_order_created( $order_id, $order = null ): void {
111 $order_id = absint( $order_id );
112 $this->dispatch( 'order.created', $this->build_order_data( $order_id, $order ), (string) $order_id );
113 }
114
115 /**
116 * Dispatch order status events.
117 *
118 * @param int $order_id Order ID.
119 * @param string $old_status Old status.
120 * @param string $new_status New status.
121 *
122 * @return void
123 */
124 public function handle_order_status_changed( $order_id, $old_status, $new_status ): void {
125 $order_id = absint( $order_id );
126 $old_status = sanitize_text_field( (string) $old_status );
127 $new_status = sanitize_text_field( (string) $new_status );
128 $data = $this->build_order_data(
129 $order_id,
130 null,
131 array(
132 'old_status' => $old_status,
133 'new_status' => $new_status,
134 )
135 );
136
137 $this->dispatch( 'order.status_changed', $data, "{$order_id}:{$old_status}:{$new_status}" );
138
139 $status_events = array(
140 'processing' => 'order.processing',
141 'completed' => 'order.completed',
142 'cancelled' => 'order.cancelled',
143 'failed' => 'order.failed',
144 'refunded' => 'order.refunded',
145 );
146
147 if ( isset( $status_events[ $new_status ] ) ) {
148 $this->dispatch( $status_events[ $new_status ], $data, "{$order_id}:{$new_status}" );
149 }
150
151 $transition_events = array(
152 'pending:processing' => 'order.pending_to_processing',
153 'pending:completed' => 'order.pending_to_completed',
154 );
155 $transition_key = "{$old_status}:{$new_status}";
156 if ( isset( $transition_events[ $transition_key ] ) ) {
157 $this->dispatch( $transition_events[ $transition_key ], $data, "{$order_id}:{$transition_key}" );
158 }
159 }
160
161 /**
162 * Dispatch checkout processed event.
163 *
164 * @param int $order_id Order ID.
165 * @param \LP_Checkout $checkout Checkout object.
166 *
167 * @return void
168 */
169 public function handle_checkout_order_processed( $order_id, $checkout = null ): void {
170 $extra = array();
171 if ( is_object( $checkout ) && method_exists( $checkout, 'get_checkout_email' ) ) {
172 $extra['checkout_email'] = sanitize_email( (string) $this->call_method( $checkout, 'get_checkout_email', '' ) );
173 }
174
175 $order_id = absint( $order_id );
176 $this->dispatch( 'checkout.order_processed', $this->build_order_data( $order_id, null, $extra ), (string) $order_id );
177 }
178
179 /**
180 * Dispatch course rejected event.
181 *
182 * @return void
183 */
184 public function handle_course_submit_rejected(): void {
185 $this->dispatch_course_submit_event( 'course.submit_rejected', func_get_args() );
186 }
187
188 /**
189 * Dispatch course approved event.
190 *
191 * @return void
192 */
193 public function handle_course_submit_approved(): void {
194 $this->dispatch_course_submit_event( 'course.submit_approved', func_get_args() );
195 }
196
197 /**
198 * Dispatch course submitted-for-review event.
199 *
200 * @return void
201 */
202 public function handle_course_submit_for_review(): void {
203 $this->dispatch_course_submit_event( 'course.submit_for_review', func_get_args() );
204 }
205
206 /**
207 * Dispatch password reset request event without exposing reset key/link.
208 *
209 * @param array $data_mail Mail data.
210 * @param string $key Reset key.
211 * @param string $user_login User login.
212 * @param \WP_User $user_data User object.
213 *
214 * @return array
215 */
216 public function handle_password_reset_requested( $data_mail, $key, $user_login, $user_data ): array {
217 unset( $key );
218
219 $user_id = is_object( $user_data ) && isset( $user_data->ID ) ? absint( $user_data->ID ) : 0;
220 $user_email = is_object( $user_data ) && isset( $user_data->user_email ) ? sanitize_email( $user_data->user_email ) : '';
221 $data = array(
222 'user_id' => $user_id,
223 'user_login' => sanitize_user( (string) $user_login ),
224 'user_email' => $user_email,
225 );
226
227 $this->dispatch( 'user.password_reset_requested', $data, $user_id > 0 ? (string) $user_id : $data['user_login'] );
228
229 return is_array( $data_mail ) ? $data_mail : array();
230 }
231
232 /**
233 * Dispatch instructor request event.
234 *
235 * @param array $request Request data.
236 *
237 * @return void
238 */
239 public function handle_instructor_requested( $request ): void {
240 $data = $this->build_instructor_request_data( is_array( $request ) ? $request : array() );
241 $key = $data['user_id'] > 0 ? (string) $data['user_id'] : $data['email'];
242
243 $this->dispatch( 'instructor.requested', $data, $key );
244 }
245
246 /**
247 * Dispatch instructor accepted event.
248 *
249 * @param string $user_email User email.
250 *
251 * @return void
252 */
253 public function handle_instructor_accepted( $user_email ): void {
254 $this->dispatch_instructor_status_event( 'instructor.accepted', $user_email );
255 }
256
257 /**
258 * Dispatch instructor denied event.
259 *
260 * @param string $user_email User email.
261 *
262 * @return void
263 */
264 public function handle_instructor_denied( $user_email ): void {
265 $this->dispatch_instructor_status_event( 'instructor.denied', $user_email );
266 }
267
268 /**
269 * Dispatch membership order completed event.
270 *
271 * @param int $order_id Order ID.
272 * @param int $user_id User ID.
273 * @param int $plan_id Membership plan ID.
274 *
275 * @return void
276 */
277 public function handle_membership_order_completed( $order_id, $user_id, $plan_id ): void {
278 $data = $this->build_membership_payload(
279 array(
280 'order_id' => $order_id,
281 'user_id' => $user_id,
282 'plan_id' => $plan_id,
283 'trigger' => 'order_completed',
284 )
285 );
286
287 $this->dispatch( 'membership.order_completed', $data, $this->get_membership_object_key( $data ) );
288 }
289
290 /**
291 * Dispatch membership renewed event.
292 *
293 * @param int $user_id User ID.
294 * @param int $plan_id Membership plan ID.
295 * @param int $order_id Parent order ID.
296 * @param int $renew_order_id Renewal order ID.
297 * @param array $webhook_data Gateway data.
298 * @param string $gateway_id Gateway ID.
299 *
300 * @return void
301 */
302 public function handle_membership_subscription_renewed( $user_id, $plan_id, $order_id, $renew_order_id, $webhook_data = array(), $gateway_id = '' ): void {
303 $data = $this->build_membership_payload(
304 array(
305 'user_id' => $user_id,
306 'plan_id' => $plan_id,
307 'order_id' => $order_id,
308 'renew_order_id' => $renew_order_id,
309 'gateway_id' => $gateway_id,
310 'webhook_data' => $webhook_data,
311 'trigger' => 'renewed',
312 )
313 );
314
315 $this->dispatch( 'membership.subscription_renewed', $data, $this->get_membership_object_key( $data ) );
316 }
317
318 /**
319 * Dispatch membership cancelled event.
320 *
321 * @param int $user_id User ID.
322 * @param int $plan_id Membership plan ID.
323 * @param int $order_id Order ID.
324 * @param array $webhook_data Gateway data.
325 * @param string $gateway_id Gateway ID.
326 *
327 * @return void
328 */
329 public function handle_membership_subscription_cancelled( $user_id, $plan_id, $order_id, $webhook_data = array(), $gateway_id = '' ): void {
330 $this->dispatch_membership_status_event( 'membership.subscription_cancelled', 'cancelled', $user_id, $plan_id, $order_id, $webhook_data, $gateway_id );
331 }
332
333 /**
334 * Dispatch membership suspended event.
335 *
336 * @param int $user_id User ID.
337 * @param int $plan_id Membership plan ID.
338 * @param int $order_id Order ID.
339 * @param array $webhook_data Gateway data.
340 * @param string $gateway_id Gateway ID.
341 *
342 * @return void
343 */
344 public function handle_membership_subscription_suspended( $user_id, $plan_id, $order_id, $webhook_data = array(), $gateway_id = '' ): void {
345 $this->dispatch_membership_status_event( 'membership.subscription_suspended', 'suspended', $user_id, $plan_id, $order_id, $webhook_data, $gateway_id );
346 }
347
348 /**
349 * Dispatch membership expired event.
350 *
351 * @param int $user_id User ID.
352 * @param int $plan_id Membership plan ID.
353 * @param int $order_id Order ID.
354 * @param array $webhook_data Gateway data.
355 * @param string $gateway_id Gateway ID.
356 *
357 * @return void
358 */
359 public function handle_membership_subscription_expired( $user_id, $plan_id = 0, $order_id = 0, $webhook_data = array(), $gateway_id = '' ): void {
360 if ( is_array( $user_id ) ) {
361 $data = $this->build_membership_payload( array_merge( $user_id, array( 'trigger' => 'expired' ) ) );
362 } else {
363 $data = $this->build_membership_payload(
364 array(
365 'user_id' => $user_id,
366 'plan_id' => $plan_id,
367 'order_id' => $order_id,
368 'gateway_id' => $gateway_id,
369 'webhook_data' => $webhook_data,
370 'trigger' => 'expired',
371 )
372 );
373 }
374
375 $this->dispatch( 'membership.subscription_expired', $data, $this->get_membership_object_key( $data ) );
376 }
377
378 /**
379 * Dispatch membership payment failed event.
380 *
381 * @param mixed $payload_or_user_id Payload map or user ID.
382 * @param int $plan_id Membership plan ID.
383 * @param int $order_id Order ID.
384 * @param int $renew_order_id Renewal order ID.
385 * @param int $grace_days Grace days.
386 * @param array $webhook_data Gateway data.
387 * @param string $gateway_id Gateway ID.
388 *
389 * @return void
390 */
391 public function handle_membership_payment_failed( $payload_or_user_id, $plan_id = 0, $order_id = 0, $renew_order_id = 0, $grace_days = 0, $webhook_data = array(), $gateway_id = '' ): void {
392 if ( is_array( $payload_or_user_id ) ) {
393 $data = $this->build_membership_payload( array_merge( $payload_or_user_id, array( 'trigger' => 'payment_failed' ) ) );
394 } else {
395 $data = $this->build_membership_payload(
396 array(
397 'user_id' => $payload_or_user_id,
398 'plan_id' => $plan_id,
399 'order_id' => $order_id,
400 'renew_order_id' => $renew_order_id,
401 'grace_days' => $grace_days,
402 'gateway_id' => $gateway_id,
403 'webhook_data' => $webhook_data,
404 'trigger' => 'payment_failed',
405 )
406 );
407 }
408
409 $this->dispatch( 'membership.payment_failed', $data, $this->get_membership_object_key( $data ) );
410 }
411
412 /**
413 * Dispatch membership reminder event.
414 *
415 * @param array $payload Reminder payload.
416 *
417 * @return void
418 */
419 public function handle_membership_reminder_expiring( $payload ): void {
420 $data = $this->build_membership_payload( is_array( $payload ) ? array_merge( $payload, array( 'trigger' => 'reminder' ) ) : array() );
421
422 $this->dispatch( 'membership.reminder_expiring', $data, $this->get_membership_object_key( $data ) );
423 }
424
425 /**
426 * Dispatch membership course resumed event.
427 *
428 * @param int $order_id Order ID.
429 * @param int $course_id Course ID.
430 * @param int $user_id User ID.
431 *
432 * @return void
433 */
434 public function handle_membership_course_resumed( $order_id, $course_id, $user_id ): void {
435 $data = array(
436 'order_id' => absint( $order_id ),
437 'course_id' => absint( $course_id ),
438 'user_id' => absint( $user_id ),
439 );
440
441 $this->dispatch( 'membership.course_resumed', $data, "{$data['order_id']}:{$data['course_id']}:{$data['user_id']}" );
442 }
443
444 /**
445 * Dispatch lesson completed event.
446 *
447 * @param object $user_item User lesson model.
448 *
449 * @return void
450 */
451 public function handle_lesson_completed( $user_item ): void {
452 $data = $this->build_user_item_event_data( $user_item );
453
454 $this->dispatch( 'lesson.completed', $data, $this->get_user_item_object_key( $data ) );
455 }
456
457 /**
458 * Dispatch quiz started event.
459 *
460 * @param object $user_quiz User quiz model.
461 *
462 * @return void
463 */
464 public function handle_quiz_started( $user_quiz ): void {
465 $data = $this->build_user_item_event_data( $user_quiz );
466
467 $this->dispatch( 'quiz.started', $data, $this->get_user_item_object_key( $data ) );
468 }
469
470 /**
471 * Dispatch quiz finished event.
472 *
473 * @param int $quiz_id Quiz ID.
474 * @param int $course_id Course ID.
475 * @param int $user_id User ID.
476 * @param object $user_quiz User quiz model.
477 *
478 * @return void
479 */
480 public function handle_quiz_finished( $quiz_id, $course_id, $user_id, $user_quiz = null ): void {
481 $data = array(
482 'quiz_id' => absint( $quiz_id ),
483 'course_id' => absint( $course_id ),
484 'user_id' => absint( $user_id ),
485 );
486
487 if ( is_object( $user_quiz ) ) {
488 $data = array_merge( $data, $this->build_user_item_event_data( $user_quiz ) );
489 }
490
491 $this->dispatch( 'quiz.finished', $data, $this->get_user_item_object_key( $data ) );
492 }
493
494 /**
495 * Dispatch quiz retried event.
496 *
497 * @param object $user_quiz User quiz model.
498 *
499 * @return void
500 */
501 public function handle_quiz_retried( $user_quiz ): void {
502 $data = $this->build_user_item_event_data( $user_quiz );
503
504 $this->dispatch( 'quiz.retried', $data, $this->get_user_item_object_key( $data ) );
505 }
506
507 /**
508 * Dispatch course finished event.
509 *
510 * @param object $user_course User course model.
511 *
512 * @return void
513 */
514 public function handle_course_finished( $user_course ): void {
515 $data = $this->build_user_item_event_data( $user_course );
516
517 $this->dispatch( 'course.finished', $data, $this->get_user_item_object_key( $data ) );
518 }
519
520 /**
521 * Dispatch user item created event.
522 *
523 * @param object $user_item User item model.
524 *
525 * @return void
526 */
527 public function handle_user_item_created( $user_item ): void {
528 $data = $this->build_user_item_event_data( $user_item );
529
530 $this->dispatch( 'user_item.created', $data, $this->get_user_item_object_key( $data ) );
531 }
532
533 /**
534 * Dispatch assignment started event.
535 *
536 * @param object $user_assignment User assignment model.
537 *
538 * @return void
539 */
540 public function handle_assignment_started( $user_assignment ): void {
541 $data = $this->build_assignment_data_from_user_item( $user_assignment );
542
543 $this->dispatch( 'assignment.started', $data, $this->get_assignment_object_key( $data ) );
544 }
545
546 /**
547 * Dispatch assignment started event from legacy hook.
548 *
549 * @param mixed $user_item_or_id User assignment model or user item ID.
550 * @param int $user_id User ID.
551 * @param int $assignment_id Assignment ID.
552 * @param int $course_id Course ID.
553 *
554 * @return void
555 */
556 public function handle_assignment_started_legacy( $user_item_or_id, $user_id = 0, $assignment_id = 0, $course_id = 0 ): void {
557 if ( is_object( $user_item_or_id ) ) {
558 $this->handle_assignment_started( $user_item_or_id );
559 return;
560 }
561
562 $data = $this->build_assignment_data( $user_id, $assignment_id, $course_id, $user_item_or_id );
563
564 $this->dispatch( 'assignment.started', $data, $this->get_assignment_object_key( $data ) );
565 }
566
567 /**
568 * Dispatch assignment submitted event.
569 *
570 * @param int $user_id User ID.
571 * @param int $assignment_id Assignment ID.
572 *
573 * @return void
574 */
575 public function handle_assignment_submitted( $user_id, $assignment_id ): void {
576 $data = $this->build_assignment_data( $user_id, $assignment_id );
577
578 $this->dispatch( 'assignment.submitted', $data, $this->get_assignment_object_key( $data ) );
579 }
580
581 /**
582 * Dispatch assignment evaluated event.
583 *
584 * @param int $user_id User ID.
585 * @param int $assignment_id Assignment ID.
586 *
587 * @return void
588 */
589 public function handle_assignment_evaluated( $user_id, $assignment_id ): void {
590 $data = $this->build_assignment_data( $user_id, $assignment_id );
591
592 $this->dispatch( 'assignment.evaluated', $data, $this->get_assignment_object_key( $data ) );
593 }
594
595 /**
596 * Dispatch assignment evaluated event from legacy hook.
597 *
598 * @param int $assignment_id Assignment ID.
599 * @param int $user_id User ID.
600 *
601 * @return void
602 */
603 public function handle_assignment_evaluated_legacy( $assignment_id, $user_id ): void {
604 $this->handle_assignment_evaluated( $user_id, $assignment_id );
605 }
606
607 /**
608 * Dispatch assignment re-evaluated event.
609 *
610 * @param int $user_id User ID.
611 * @param int $assignment_id Assignment ID.
612 *
613 * @return void
614 */
615 public function handle_assignment_re_evaluated( $user_id, $assignment_id ): void {
616 $data = $this->build_assignment_data( $user_id, $assignment_id );
617
618 $this->dispatch( 'assignment.re_evaluated', $data, $this->get_assignment_object_key( $data ) );
619 }
620
621 /**
622 * Dispatch assignment re-evaluated event from legacy hook.
623 *
624 * @param int $assignment_id Assignment ID.
625 * @param int $user_id User ID.
626 *
627 * @return void
628 */
629 public function handle_assignment_re_evaluated_legacy( $assignment_id, $user_id ): void {
630 $this->handle_assignment_re_evaluated( $user_id, $assignment_id );
631 }
632
633 /**
634 * Dispatch assignment retried event.
635 *
636 * @param object $user_assignment User assignment model.
637 *
638 * @return void
639 */
640 public function handle_assignment_retried( $user_assignment ): void {
641 $data = $this->build_assignment_data_from_user_item( $user_assignment );
642
643 $this->dispatch( 'assignment.retried', $data, $this->get_assignment_object_key( $data ) );
644 }
645
646 /**
647 * Dispatch assignment retried event from legacy hook.
648 *
649 * @param int $user_item_id User item ID.
650 * @param int $user_id User ID.
651 * @param int $assignment_id Assignment ID.
652 * @param int $course_id Course ID.
653 *
654 * @return void
655 */
656 public function handle_assignment_retried_legacy( $user_item_id, $user_id, $assignment_id, $course_id ): void {
657 $data = $this->build_assignment_data( $user_id, $assignment_id, $course_id, $user_item_id );
658
659 $this->dispatch( 'assignment.retried', $data, $this->get_assignment_object_key( $data ) );
660 }
661
662 /**
663 * Dispatch announcement created event.
664 *
665 * @param int $announcement_id Announcement ID.
666 * @param array $course_ids Course IDs.
667 * @param bool $send_mail Whether email was requested.
668 *
669 * @return void
670 */
671 public function handle_announcement_created( $announcement_id, $course_ids = array(), $send_mail = false ): void {
672 $data = $this->build_announcement_data( $announcement_id, $course_ids, $send_mail );
673
674 $this->dispatch( 'announcement.created', $data, (string) $data['announcement_id'] );
675 }
676
677 /**
678 * Dispatch announcement email queued event.
679 *
680 * @param int $announcement_id Announcement ID.
681 * @param array $course_ids Course IDs.
682 *
683 * @return void
684 */
685 public function handle_announcement_email_queued( $announcement_id, $course_ids = array() ): void {
686 $data = $this->build_announcement_data( $announcement_id, $course_ids, true );
687
688 $this->dispatch( 'announcement.email_queued', $data, (string) $data['announcement_id'] );
689 }
690
691 /**
692 * Dispatch an event to matching active webhooks.
693 *
694 * @param string $event_key Event key.
695 * @param array $data Event data.
696 * @param string $object_key Stable object key for in-request dedupe.
697 *
698 * @return int Number of delivery attempts.
699 */
700 public function dispatch( string $event_key, array $data = array(), string $object_key = '' ): int {
701 if ( ! $this->is_enabled() ) {
702 return 0;
703 }
704
705 $event_keys = WebhookEvents::sanitize( array( $event_key ) );
706 if ( empty( $event_keys ) ) {
707 return 0;
708 }
709
710 $event_key = reset( $event_keys );
711 $object_key = '' !== $object_key ? $object_key : md5( wp_json_encode( $data ) );
712
713 try {
714 $webhooks = WebhookModel::get_active_webhooks_for_event( $event_key );
715 } catch ( Throwable $e ) {
716 return 0;
717 }
718
719 $sent = 0;
720 foreach ( $webhooks as $webhook ) {
721 if ( ! $webhook instanceof WebhookModel ) {
722 continue;
723 }
724
725 $dedupe_key = "{$event_key}:{$object_key}:{$webhook->get_webhook_id()}";
726 if ( isset( $this->processed[ $dedupe_key ] ) ) {
727 continue;
728 }
729
730 $this->processed[ $dedupe_key ] = true;
731 if ( $this->deliver( $webhook, $event_key, $data ) ) {
732 ++$sent;
733 }
734 }
735
736 return $sent;
737 }
738
739 /**
740 * Deliver a webhook HTTP request.
741 *
742 * @param WebhookModel $webhook Webhook model.
743 * @param string $event_key Event key.
744 * @param array $data Event data.
745 *
746 * @return bool
747 */
748 protected function deliver( WebhookModel $webhook, string $event_key, array $data ): bool {
749 $delivery_id = $this->generate_delivery_id();
750 $payload = WebhookResourceSerializer::instance()->build_payload( $event_key, $delivery_id, $data );
751
752 $payload_filtered = apply_filters( 'learn-press/webhook/payload', $payload, $event_key, $webhook );
753 if ( is_array( $payload_filtered ) ) {
754 $payload = $payload_filtered;
755 }
756
757 $body = wp_json_encode( $payload, JSON_UNESCAPED_SLASHES );
758 if ( ! is_string( $body ) || '' === $body ) {
759 return false;
760 }
761
762 $args = array(
763 'method' => 'POST',
764 'timeout' => 10,
765 'redirection' => 0,
766 'blocking' => true,
767 'headers' => array(
768 'Content-Type' => 'application/json',
769 'X-LP-Webhook-Source' => home_url( '/' ),
770 'X-LP-Webhook-Event' => $event_key,
771 'X-LP-Webhook-ID' => (string) $webhook->get_webhook_id(),
772 'X-LP-Webhook-Delivery-ID' => $delivery_id,
773 'X-LP-Webhook-Signature' => self::sign_body( $body, $webhook->secret ),
774 'X-LearnPress-Webhook-Event' => $event_key,
775 'X-LearnPress-Webhook-ID' => (string) $webhook->get_webhook_id(),
776 'X-LearnPress-Webhook-Signature' => self::sign_body( $body, $webhook->secret ),
777 ),
778 'body' => $body,
779 );
780
781 $args = apply_filters( 'learn-press/webhook/http-args', $args, $webhook, $payload );
782 if ( ! is_array( $args ) ) {
783 return false;
784 }
785
786 try {
787 $response = wp_safe_remote_post( $webhook->delivery_url, $args );
788 } catch ( Throwable $e ) {
789 $response = $e;
790 }
791
792 $this->log_delivery_result( $webhook, $event_key, $delivery_id, $response );
793
794 do_action( 'learn-press/webhook/delivered', $webhook, $payload, $response, $args );
795
796 return $this->is_delivery_successful( $response );
797 }
798
799 /**
800 * Check whether the HTTP delivery was accepted by the receiver.
801 *
802 * @param mixed $response HTTP response, WP_Error, or Throwable.
803 *
804 * @return bool
805 */
806 protected function is_delivery_successful( $response ): bool {
807 if ( $response instanceof Throwable || is_wp_error( $response ) ) {
808 return false;
809 }
810
811 $status_code = absint( wp_remote_retrieve_response_code( $response ) );
812
813 return $status_code >= 200 && $status_code < 300;
814 }
815
816 /**
817 * Log failed delivery details, and successful deliveries when explicitly enabled.
818 *
819 * @param WebhookModel $webhook Webhook model.
820 * @param string $event_key Event key.
821 * @param string $delivery_id Delivery ID.
822 * @param mixed $response HTTP response, WP_Error, or Throwable.
823 *
824 * @return void
825 */
826 protected function log_delivery_result( WebhookModel $webhook, string $event_key, string $delivery_id, $response ): void {
827 $context = array(
828 'event' => $event_key,
829 'webhook_id' => $webhook->get_webhook_id(),
830 'delivery_id' => $delivery_id,
831 'url' => $this->redact_url_for_log( $webhook->delivery_url ),
832 );
833
834 if ( $response instanceof Throwable ) {
835 $this->write_delivery_log(
836 'error',
837 array_merge(
838 $context,
839 array(
840 'error_type' => get_class( $response ),
841 'error_message' => $response->getMessage(),
842 )
843 )
844 );
845
846 return;
847 }
848
849 if ( is_wp_error( $response ) ) {
850 $this->write_delivery_log(
851 'error',
852 array_merge(
853 $context,
854 array(
855 'error_code' => $response->get_error_code(),
856 'error_message' => $response->get_error_message(),
857 )
858 )
859 );
860
861 return;
862 }
863
864 $status_code = absint( wp_remote_retrieve_response_code( $response ) );
865 $context = array_merge(
866 $context,
867 array(
868 'status_code' => $status_code,
869 'response_message' => wp_remote_retrieve_response_message( $response ),
870 )
871 );
872
873 if ( $status_code < 200 || $status_code >= 300 ) {
874 $this->write_delivery_log(
875 'error',
876 array_merge(
877 $context,
878 array(
879 'response_body' => $this->truncate_log_value( wp_remote_retrieve_body( $response ) ),
880 )
881 )
882 );
883
884 return;
885 }
886
887 if ( $this->should_log_successful_delivery( $webhook, $event_key, $delivery_id, $response ) ) {
888 $this->write_delivery_log( 'info', $context );
889 }
890 }
891
892 /**
893 * Check whether successful deliveries should be written to error_log.
894 *
895 * @param WebhookModel $webhook Webhook model.
896 * @param string $event_key Event key.
897 * @param string $delivery_id Delivery ID.
898 * @param mixed $response HTTP response.
899 *
900 * @return bool
901 */
902 protected function should_log_successful_delivery( WebhookModel $webhook, string $event_key, string $delivery_id, $response ): bool {
903 return (bool) apply_filters(
904 'learn-press/webhook/log-successful-delivery',
905 false,
906 $webhook,
907 $event_key,
908 $delivery_id,
909 $response
910 );
911 }
912
913 /**
914 * Write a structured webhook delivery log entry.
915 *
916 * @param string $level Log level.
917 * @param array $context Log context.
918 *
919 * @return void
920 */
921 protected function write_delivery_log( string $level, array $context ): void {
922 $message = wp_json_encode(
923 array_merge(
924 array(
925 'level' => $level,
926 ),
927 $context
928 ),
929 JSON_UNESCAPED_SLASHES
930 );
931
932 error_log( '[LearnPress webhook delivery] ' . ( is_string( $message ) ? $message : print_r( $context, true ) ) );
933 }
934
935 /**
936 * Trim long response bodies before writing to error_log.
937 *
938 * @param mixed $value Log value.
939 * @param int $limit Maximum length.
940 *
941 * @return string
942 */
943 protected function truncate_log_value( $value, int $limit = 1000 ): string {
944 $value = (string) $value;
945
946 if ( strlen( $value ) <= $limit ) {
947 return $value;
948 }
949
950 return substr( $value, 0, $limit ) . '...';
951 }
952
953 /**
954 * Remove query string and credentials from delivery URLs before logging.
955 *
956 * @param string $url Delivery URL.
957 *
958 * @return string
959 */
960 protected function redact_url_for_log( string $url ): string {
961 $parts = wp_parse_url( $url );
962
963 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
964 return '';
965 }
966
967 $scheme = ! empty( $parts['scheme'] ) ? $parts['scheme'] . '://' : '';
968 $port = ! empty( $parts['port'] ) ? ':' . absint( $parts['port'] ) : '';
969 $path = $parts['path'] ?? '';
970
971 return $scheme . $parts['host'] . $port . $path;
972 }
973
974 /**
975 * Check global webhook setting.
976 *
977 * @return bool
978 */
979 protected function is_enabled(): bool {
980 return 'yes' === \LP_Settings::get_option( 'enable_webhook_integration', 'no' );
981 }
982
983 /**
984 * Generate HMAC SHA256 signature for request body.
985 *
986 * @param string $body Raw JSON body.
987 * @param string $secret Webhook secret.
988 *
989 * @return string
990 */
991 public static function sign_body( string $body, string $secret ): string {
992 return base64_encode( hash_hmac( 'sha256', $body, $secret, true ) );
993 }
994
995 /**
996 * Dispatch old course submit email hooks.
997 *
998 * @param string $event_key Event key.
999 * @param array $args Hook args.
1000 *
1001 * @return void
1002 */
1003 protected function dispatch_course_submit_event( string $event_key, array $args ): void {
1004 $data = array(
1005 'hook' => current_filter(),
1006 'args' => $this->sanitize_payload_value( $args ),
1007 );
1008
1009 $object_key = '';
1010 if ( isset( $args[0] ) && is_scalar( $args[0] ) ) {
1011 $object_key = sanitize_text_field( (string) $args[0] );
1012 }
1013
1014 $this->dispatch( $event_key, $data, $object_key );
1015 }
1016
1017 /**
1018 * Build instructor request payload data.
1019 *
1020 * @param array $request Request data.
1021 *
1022 * @return array<string, mixed>
1023 */
1024 protected function build_instructor_request_data( array $request ): array {
1025 $email = sanitize_email( (string) ( $request['bat_email'] ?? '' ) );
1026 $user = $email ? get_user_by( 'email', $email ) : false;
1027
1028 return array(
1029 'user_id' => $user instanceof \WP_User ? absint( $user->ID ) : 0,
1030 'name' => sanitize_text_field( (string) ( $request['bat_name'] ?? '' ) ),
1031 'email' => $email,
1032 'phone' => sanitize_text_field( (string) ( $request['bat_phone'] ?? '' ) ),
1033 'message' => sanitize_textarea_field( (string) ( $request['bat_message'] ?? '' ) ),
1034 );
1035 }
1036
1037 /**
1038 * Dispatch instructor status payload by user email.
1039 *
1040 * @param string $event_key Event key.
1041 * @param string $user_email User email.
1042 *
1043 * @return void
1044 */
1045 protected function dispatch_instructor_status_event( string $event_key, string $user_email ): void {
1046 $user_email = sanitize_email( $user_email );
1047 $user = $user_email ? get_user_by( 'email', $user_email ) : false;
1048 $user_id = $user instanceof \WP_User ? absint( $user->ID ) : 0;
1049 $data = array(
1050 'user_id' => $user_id,
1051 'user_email' => $user_email,
1052 );
1053
1054 $this->dispatch( $event_key, $data, $user_id > 0 ? (string) $user_id : $user_email );
1055 }
1056
1057 /**
1058 * Dispatch a membership status event.
1059 *
1060 * @param string $event_key Event key.
1061 * @param string $trigger Trigger key.
1062 * @param int $user_id User ID.
1063 * @param int $plan_id Membership plan ID.
1064 * @param int $order_id Order ID.
1065 * @param array $webhook_data Gateway data.
1066 * @param string $gateway_id Gateway ID.
1067 *
1068 * @return void
1069 */
1070 protected function dispatch_membership_status_event( string $event_key, string $trigger, $user_id, $plan_id, $order_id, $webhook_data = array(), $gateway_id = '' ): void {
1071 $data = $this->build_membership_payload(
1072 array(
1073 'user_id' => $user_id,
1074 'plan_id' => $plan_id,
1075 'order_id' => $order_id,
1076 'gateway_id' => $gateway_id,
1077 'webhook_data' => $webhook_data,
1078 'trigger' => $trigger,
1079 )
1080 );
1081
1082 $this->dispatch( $event_key, $data, $this->get_membership_object_key( $data ) );
1083 }
1084
1085 /**
1086 * Build normalized membership payload.
1087 *
1088 * @param array $payload Raw payload.
1089 *
1090 * @return array<string, mixed>
1091 */
1092 protected function build_membership_payload( array $payload ): array {
1093 $days_left = $payload['days_left'] ?? null;
1094 $grace_days = $payload['grace_days'] ?? null;
1095 $webhook_data = $payload['webhook_data'] ?? array();
1096
1097 return array(
1098 'order_id' => absint( $payload['order_id'] ?? 0 ),
1099 'user_id' => absint( $payload['user_id'] ?? 0 ),
1100 'plan_id' => absint( $payload['plan_id'] ?? 0 ),
1101 'member_id' => absint( $payload['member_id'] ?? 0 ),
1102 'renew_order_id' => absint( $payload['renew_order_id'] ?? 0 ),
1103 'days_left' => null !== $days_left ? max( 0, (int) $days_left ) : null,
1104 'grace_days' => null !== $grace_days ? max( 0, absint( $grace_days ) ) : null,
1105 'gateway_id' => sanitize_key( (string) ( $payload['gateway_id'] ?? '' ) ),
1106 'trigger' => sanitize_key( (string) ( $payload['trigger'] ?? '' ) ),
1107 'webhook_data' => is_array( $webhook_data ) ? $this->sanitize_payload_value( $webhook_data ) : array(),
1108 );
1109 }
1110
1111 /**
1112 * Get stable membership event object key.
1113 *
1114 * @param array $data Payload data.
1115 *
1116 * @return string
1117 */
1118 protected function get_membership_object_key( array $data ): string {
1119 $primary_id = absint( $data['renew_order_id'] ?? 0 );
1120 if ( ! $primary_id ) {
1121 $primary_id = absint( $data['order_id'] ?? 0 );
1122 }
1123 if ( ! $primary_id ) {
1124 $primary_id = absint( $data['member_id'] ?? 0 );
1125 }
1126
1127 return sprintf(
1128 '%d:%d:%d:%s:%s',
1129 absint( $data['user_id'] ?? 0 ),
1130 absint( $data['plan_id'] ?? 0 ),
1131 $primary_id,
1132 sanitize_key( (string) ( $data['trigger'] ?? '' ) ),
1133 sanitize_text_field( (string) ( $data['days_left'] ?? '' ) )
1134 );
1135 }
1136
1137 /**
1138 * Build assignment payload from a user item model.
1139 *
1140 * @param object $user_assignment User assignment model.
1141 *
1142 * @return array<string, mixed>
1143 */
1144 protected function build_assignment_data_from_user_item( $user_assignment ): array {
1145 $data = $this->build_user_item_event_data( $user_assignment );
1146 $user_item = is_array( $data['user_item'] ?? null ) ? $data['user_item'] : array();
1147 $data['assignment_id'] = absint( $data['item_id'] ?? 0 );
1148 $data['course_id'] = absint( $data['ref_id'] ?? 0 );
1149 $data['user_item_id'] = absint( $user_item['user_item_id'] ?? 0 );
1150
1151 return $data;
1152 }
1153
1154 /**
1155 * Build assignment payload.
1156 *
1157 * @param int $user_id User ID.
1158 * @param int $assignment_id Assignment ID.
1159 * @param int $course_id Course ID.
1160 * @param int $user_item_id User item ID.
1161 *
1162 * @return array<string, mixed>
1163 */
1164 protected function build_assignment_data( $user_id, $assignment_id, $course_id = 0, $user_item_id = 0 ): array {
1165 $assignment_id = absint( $assignment_id );
1166 $course_id = absint( $course_id );
1167
1168 return array(
1169 'user_id' => absint( $user_id ),
1170 'assignment_id' => $assignment_id,
1171 'item_id' => $assignment_id,
1172 'item_type' => 'lp_assignment',
1173 'course_id' => $course_id,
1174 'ref_id' => $course_id,
1175 'ref_type' => LP_COURSE_CPT,
1176 'user_item_id' => absint( $user_item_id ),
1177 );
1178 }
1179
1180 /**
1181 * Get stable assignment event object key.
1182 *
1183 * @param array $data Payload data.
1184 *
1185 * @return string
1186 */
1187 protected function get_assignment_object_key( array $data ): string {
1188 $user_item_id = absint( $data['user_item_id'] ?? 0 );
1189 if ( $user_item_id > 0 ) {
1190 return (string) $user_item_id;
1191 }
1192
1193 return sprintf(
1194 '%d:%d:%d',
1195 absint( $data['user_id'] ?? 0 ),
1196 absint( $data['assignment_id'] ?? $data['item_id'] ?? 0 ),
1197 absint( $data['course_id'] ?? $data['ref_id'] ?? 0 )
1198 );
1199 }
1200
1201 /**
1202 * Build announcement payload.
1203 *
1204 * @param int $announcement_id Announcement ID.
1205 * @param array $course_ids Course IDs.
1206 * @param bool $send_mail Whether email was requested.
1207 *
1208 * @return array<string, mixed>
1209 */
1210 protected function build_announcement_data( $announcement_id, $course_ids = array(), bool $send_mail = false ): array {
1211 $announcement_id = absint( $announcement_id );
1212 $course_ids = is_array( $course_ids ) ? array_values( array_filter( array_map( 'absint', $course_ids ) ) ) : array();
1213 $post = $announcement_id > 0 ? get_post( $announcement_id ) : null;
1214
1215 return array(
1216 'announcement_id' => $announcement_id,
1217 'course_ids' => $course_ids,
1218 'send_mail' => $send_mail,
1219 'title' => $post instanceof \WP_Post ? sanitize_text_field( $post->post_title ) : '',
1220 'author_id' => $post instanceof \WP_Post ? absint( $post->post_author ) : 0,
1221 );
1222 }
1223
1224 /**
1225 * Build normalized order payload data.
1226 *
1227 * @param int $order_id Order ID.
1228 * @param \LP_Order|null $order Order object.
1229 * @param array $extra Extra payload data.
1230 *
1231 * @return array<string, mixed>
1232 */
1233 protected function build_order_data( int $order_id, $order = null, array $extra = array() ): array {
1234 if ( ! is_object( $order ) && function_exists( 'learn_press_get_order' ) ) {
1235 $order = learn_press_get_order( $order_id );
1236 }
1237
1238 $data = array(
1239 'order_id' => $order_id,
1240 );
1241
1242 if ( is_object( $order ) ) {
1243 $user_id = $this->call_method( $order, 'get_user_id', 0 );
1244 if ( is_array( $user_id ) ) {
1245 $user_id = array_values( array_map( 'absint', $user_id ) );
1246 } else {
1247 $user_id = absint( $user_id );
1248 }
1249
1250 $data = array_merge(
1251 $data,
1252 array(
1253 'status' => sanitize_text_field( (string) $this->call_method( $order, 'get_status', '' ) ),
1254 'user_id' => $user_id,
1255 'total' => $this->call_method( $order, 'get_total', 0 ),
1256 'subtotal' => $this->call_method( $order, 'get_subtotal', 0 ),
1257 'currency' => sanitize_text_field( (string) $this->call_method( $order, 'get_currency', '' ) ),
1258 'created_via' => sanitize_text_field( (string) $this->call_method( $order, 'get_created_via', '' ) ),
1259 'checkout_email' => sanitize_email( (string) $this->call_method( $order, 'get_checkout_email', '' ) ),
1260 'items' => $this->build_order_items_data( $order ),
1261 )
1262 );
1263 }
1264
1265 return array_merge( $data, $extra );
1266 }
1267
1268 /**
1269 * Build normalized order item data.
1270 *
1271 * @param object $order Order object.
1272 *
1273 * @return array<int, array<string, mixed>>
1274 */
1275 protected function build_order_items_data( $order ): array {
1276 $items = $this->call_method( $order, 'get_all_items', array() );
1277 if ( ! is_array( $items ) ) {
1278 return array();
1279 }
1280
1281 $data = array();
1282 foreach ( $items as $item ) {
1283 if ( ! is_array( $item ) ) {
1284 $item = (array) $item;
1285 }
1286
1287 $data[] = array(
1288 'order_item_id' => absint( $item['order_item_id'] ?? 0 ),
1289 'order_item_name' => sanitize_text_field( (string) ( $item['order_item_name'] ?? '' ) ),
1290 'item_id' => absint( $item['item_id'] ?? 0 ),
1291 'item_type' => sanitize_key( (string) ( $item['item_type'] ?? '' ) ),
1292 'quantity' => absint( $item['quantity'] ?? $item['qty'] ?? 0 ),
1293 'subtotal' => $item['subtotal'] ?? '',
1294 'total' => $item['total'] ?? '',
1295 );
1296 }
1297
1298 return $data;
1299 }
1300
1301 /**
1302 * Build normalized user item payload data.
1303 *
1304 * @param object $user_item User item object.
1305 *
1306 * @return array<string, mixed>
1307 */
1308 protected function build_user_item_event_data( $user_item ): array {
1309 $user_item_data = $this->build_user_item_data( $user_item );
1310
1311 return array(
1312 'user_id' => $user_item_data['user_id'],
1313 'item_id' => $user_item_data['item_id'],
1314 'item_type' => $user_item_data['item_type'],
1315 'ref_id' => $user_item_data['ref_id'],
1316 'ref_type' => $user_item_data['ref_type'],
1317 'user_item' => $user_item_data,
1318 );
1319 }
1320
1321 /**
1322 * Build user item data.
1323 *
1324 * @param object $user_item User item object.
1325 *
1326 * @return array<string, mixed>
1327 */
1328 protected function build_user_item_data( $user_item ): array {
1329 return array(
1330 '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,
1331 'user_id' => absint( $this->read_property( $user_item, 'user_id', 0 ) ),
1332 'item_id' => absint( $this->read_property( $user_item, 'item_id', 0 ) ),
1333 'item_type' => sanitize_key( (string) $this->read_property( $user_item, 'item_type', '' ) ),
1334 'status' => sanitize_key( (string) $this->read_property( $user_item, 'status', '' ) ),
1335 'graduation' => sanitize_key( (string) $this->read_property( $user_item, 'graduation', '' ) ),
1336 'ref_id' => absint( $this->read_property( $user_item, 'ref_id', 0 ) ),
1337 'ref_type' => sanitize_key( (string) $this->read_property( $user_item, 'ref_type', '' ) ),
1338 'parent_id' => absint( $this->read_property( $user_item, 'parent_id', 0 ) ),
1339 'start_time' => sanitize_text_field( (string) $this->read_property( $user_item, 'start_time', '' ) ),
1340 'end_time' => sanitize_text_field( (string) $this->read_property( $user_item, 'end_time', '' ) ),
1341 );
1342 }
1343
1344 /**
1345 * Get stable key for user-item-like payload data.
1346 *
1347 * @param array $data Payload data.
1348 *
1349 * @return string
1350 */
1351 protected function get_user_item_object_key( array $data ): string {
1352 $user_item = $data['user_item'] ?? array();
1353 if ( ! is_array( $user_item ) ) {
1354 $user_item = array();
1355 }
1356
1357 $user_item_id = absint( $user_item['user_item_id'] ?? 0 );
1358 if ( $user_item_id > 0 ) {
1359 return (string) $user_item_id;
1360 }
1361
1362 return sprintf(
1363 '%d:%d:%s:%d:%s',
1364 absint( $data['user_id'] ?? 0 ),
1365 absint( $data['item_id'] ?? 0 ),
1366 sanitize_key( (string) ( $data['item_type'] ?? '' ) ),
1367 absint( $data['ref_id'] ?? 0 ),
1368 sanitize_key( (string) ( $data['ref_type'] ?? '' ) )
1369 );
1370 }
1371
1372 /**
1373 * Sanitize nested payload values without serializing objects wholesale.
1374 *
1375 * @param mixed $value Raw value.
1376 *
1377 * @return mixed
1378 */
1379 protected function sanitize_payload_value( $value ) {
1380 if ( is_null( $value ) || is_bool( $value ) || is_int( $value ) || is_float( $value ) ) {
1381 return $value;
1382 }
1383
1384 if ( is_string( $value ) ) {
1385 return sanitize_text_field( $value );
1386 }
1387
1388 if ( is_array( $value ) ) {
1389 $clean = array();
1390 foreach ( $value as $key => $item ) {
1391 $clean_key = is_int( $key ) ? $key : sanitize_key( (string) $key );
1392 $clean[ $clean_key ] = $this->sanitize_payload_value( $item );
1393 }
1394
1395 return $clean;
1396 }
1397
1398 if ( is_object( $value ) ) {
1399 $data = array(
1400 'class' => get_class( $value ),
1401 );
1402 if ( method_exists( $value, 'get_id' ) ) {
1403 $data['id'] = absint( $this->call_method( $value, 'get_id', 0 ) );
1404 }
1405
1406 return $data;
1407 }
1408
1409 return sanitize_text_field( (string) $value );
1410 }
1411
1412 /**
1413 * Call an object method when it is available.
1414 *
1415 * @param object $target Target object.
1416 * @param string $method Method name.
1417 * @param mixed $fallback Fallback value.
1418 *
1419 * @return mixed
1420 */
1421 protected function call_method( $target, string $method, $fallback = null ) {
1422 if ( ! is_object( $target ) || ! method_exists( $target, $method ) ) {
1423 return $fallback;
1424 }
1425
1426 return $target->{$method}();
1427 }
1428
1429 /**
1430 * Safely read public object property.
1431 *
1432 * @param mixed $target Target object.
1433 * @param string $property Property name.
1434 * @param mixed $fallback Fallback value.
1435 *
1436 * @return mixed
1437 */
1438 protected function read_property( $target, string $property, $fallback = null ) {
1439 return is_object( $target ) && isset( $target->{$property} ) ? $target->{$property} : $fallback;
1440 }
1441
1442 /**
1443 * Generate a delivery ID.
1444 *
1445 * @return string
1446 */
1447 protected function generate_delivery_id(): string {
1448 if ( function_exists( 'wp_generate_uuid4' ) ) {
1449 return 'lpwh_' . wp_generate_uuid4();
1450 }
1451
1452 return 'lpwh_' . md5( uniqid( '', true ) );
1453 }
1454 }
1455