PluginProbe
Pushly / 2.4.0
Pushly v2.4.0
2.4.0 2.3.0 trunk 1.0 1.0.0 1.1.0 1.1.1 1.1.2 1.1.3 2.0.0 2.1.0 2.1.1 2.1.10 2.1.11 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.1.9 2.2.0 2.2.1
pushly / src / php / Admin / Post.php

Post.php in Pushly 2.4.0, at src/php/Admin/Post.php

722 lines 23.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Pushly\Admin;
4
5 use Pushly\Models\Notification;
6
7 if ( ! defined( 'ABSPATH' ) ) {
8 exit;
9 }
10
11 class Post {
12 private array $options;
13
14 private ?LogWriter $log_writer;
15
16 public function __construct( array $options, ?LogWriter $log_writer = null ) {
17 $this->options = $options;
18 $this->log_writer = $log_writer;
19 }
20
21 /**
22 * Delegates to LogWriter::log() when a LogWriter is available, otherwise no-ops.
23 *
24 * @param string $severity One of: debug, info, warning, error.
25 * @param string $event_type Machine-readable event identifier.
26 * @param string $message Human-readable message.
27 * @param int|null $post_id Associated post ID, if applicable.
28 * @param array|null $context Additional structured data.
29 */
30 private function log( string $severity, string $event_type, string $message, ?int $post_id = null, ?array $context = null ): void {
31 if ( $this->log_writer === null ) {
32 return;
33 }
34
35 $this->log_writer->log( $severity, $event_type, $message, $post_id, $context );
36 }
37
38 /**
39 * Builds the standard log message prefix for send-flow log entries.
40 *
41 * Format: "Post '{title}' (ID: {id}, type: {type}, editor: {editor})"
42 */
43 private function build_log_prefix( \WP_Post $post ): string {
44 $editor = ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ? 'gutenberg' : 'classic';
45
46 return sprintf(
47 "Post '%s' (ID: %d, type: %s, editor: %s)",
48 $post->post_title,
49 $post->ID,
50 $post->post_type,
51 $editor
52 );
53 }
54
55 public function register_hooks(): void {
56 if ( ! $this->is_sending_configured() ) {
57 return;
58 }
59
60 add_action( 'admin_init', [ $this, 'register_post_meta' ] );
61 add_action( 'rest_api_init', [ $this, 'register_post_meta' ] );
62 add_action( 'enqueue_block_editor_assets', [ $this, 'enqueue_meta_box_assets_for_gutenberg' ] );
63 add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_meta_box_assets_for_classic' ] );
64 add_action( 'transition_post_status', [ $this, 'transition_post_status' ], 10, 3 );
65 add_action( 'add_meta_boxes', [ $this, 'add_meta_box_for_classic' ] );
66 add_action( 'admin_notices', [ $this, 'emit_notice' ] );
67 add_action( 'rest_api_init', [ $this, 'register_api_routes' ] );
68 }
69
70 /**
71 * Returns true when all required settings for notification sending are present.
72 */
73 private function is_sending_configured(): bool {
74 return ! empty( $this->options['sdk_key'] )
75 && ! empty( $this->options['sending_enabled'] )
76 && ! empty( $this->options['api_key'] );
77 }
78
79 // -------------------------------------------------------------------------
80 // Meta Registration
81 // -------------------------------------------------------------------------
82
83 public function register_post_meta(): void {
84 if ( empty( $this->options['enabled_post_types'] ) ) {
85 return;
86 }
87
88 foreach ( $this->options['enabled_post_types'] as $post_type ) {
89 register_post_meta( $post_type, 'pushly_notification_id', [
90 'single' => true,
91 'type' => 'string',
92 'show_in_rest' => true,
93 ] );
94
95 register_post_meta( $post_type, 'pushly_send_notification', [
96 'single' => true,
97 'type' => 'boolean',
98 'default' => ! empty( $this->options['auto_send_enabled'] ),
99 'show_in_rest' => true,
100 'sanitize_callback' => 'wp_validate_boolean',
101 ] );
102
103 register_post_meta( $post_type, 'pushly_customize_notification_content', [
104 'single' => true,
105 'type' => 'boolean',
106 'show_in_rest' => true,
107 'sanitize_callback' => 'wp_validate_boolean',
108 ] );
109
110 register_post_meta( $post_type, 'pushly_custom_title', [
111 'single' => true,
112 'type' => 'string',
113 'show_in_rest' => true,
114 'sanitize_callback' => 'sanitize_text_field',
115 ] );
116
117 register_post_meta( $post_type, 'pushly_custom_body', [
118 'single' => true,
119 'type' => 'string',
120 'show_in_rest' => true,
121 'sanitize_callback' => 'sanitize_text_field',
122 ] );
123
124 register_post_meta( $post_type, 'pushly_customize_audience', [
125 'single' => true,
126 'type' => 'boolean',
127 'default' => false,
128 'show_in_rest' => true,
129 'sanitize_callback' => 'wp_validate_boolean',
130 ] );
131
132 register_post_meta( $post_type, 'pushly_audience_ids', [
133 'single' => true,
134 'type' => 'array',
135 'show_in_rest' => [
136 'schema' => [
137 'type' => 'array',
138 'items' => [ 'type' => 'integer' ],
139 ],
140 ],
141 ] );
142
143 register_post_meta( $post_type, 'pushly_excluded_audience_ids', [
144 'single' => true,
145 'type' => 'array',
146 'show_in_rest' => [
147 'schema' => [
148 'type' => 'array',
149 'items' => [ 'type' => 'integer' ],
150 ],
151 ],
152 ] );
153 }
154 }
155
156 // -------------------------------------------------------------------------
157 // Asset Enqueueing
158 // -------------------------------------------------------------------------
159
160 public function enqueue_meta_box_assets_for_gutenberg(): void {
161 if ( ! $this->is_enabled_post_screen() ) {
162 return;
163 }
164
165 $asset_file = PUSHLY__DIR_BUILD . '/meta-box.asset.php';
166 if ( ! file_exists( $asset_file ) ) {
167 return;
168 }
169
170 $asset = include $asset_file;
171
172 wp_enqueue_script(
173 'pushly',
174 plugins_url( 'build/meta-box.js', PUSHLY__DIR_BUILD ),
175 $asset['dependencies'],
176 $asset['version'],
177 true
178 );
179
180 wp_enqueue_style(
181 'pushly-style',
182 plugins_url( 'build/meta-box.css', PUSHLY__DIR_BUILD ),
183 array_filter( $asset['dependencies'], fn( $style ) => wp_style_is( $style, 'registered' ) ),
184 $asset['version']
185 );
186 }
187
188 public function enqueue_meta_box_assets_for_classic(): void {
189 if ( ! $this->is_enabled_post_screen() || $this->is_using_gutenberg() ) {
190 return;
191 }
192
193 wp_enqueue_script(
194 'pushly-classic',
195 plugins_url( 'includes/admin/views/classic/meta-box.js', PUSHLY__DIR . '/pushly.php' ),
196 [ 'jquery' ],
197 PUSHLY__PLUGIN_VERSION,
198 true
199 );
200 }
201
202 // -------------------------------------------------------------------------
203 // Classic Editor Meta Box
204 // -------------------------------------------------------------------------
205
206 public function add_meta_box_for_classic(): void {
207 if ( empty( $this->options['enabled_post_types'] ) ) {
208 return;
209 }
210
211 add_meta_box(
212 'pushly_meta_box',
213 __( 'Pushly Notifications', 'pushly' ),
214 [ $this, 'build_classic_meta_box' ],
215 $this->options['enabled_post_types'],
216 'side',
217 'default',
218 [ '__back_compat_meta_box' => true ]
219 );
220 }
221
222 public function build_classic_meta_box( \WP_Post $post ): void {
223 $meta = $this->get_post_meta( $post->ID );
224
225 $send_notification = $meta['pushly_send_notification'] ?? ! empty( $this->options['auto_send_enabled'] );
226 $customize_notification_content = ! empty( $meta['pushly_customize_notification_content'] );
227 $custom_title = $meta['pushly_custom_title'] ?? null;
228 $custom_body = $meta['pushly_custom_body'] ?? null;
229
230 require_once PUSHLY__DIR . '/includes/admin/views/classic/meta-box.php';
231 pushly_render_classic_meta_box(
232 $send_notification,
233 $customize_notification_content,
234 $custom_title,
235 $custom_body
236 );
237 }
238
239 // -------------------------------------------------------------------------
240 // Notification Send Flow
241 // -------------------------------------------------------------------------
242
243 public function transition_post_status( string $new_status, string $old_status, \WP_Post $post ): void {
244 if ( empty( $this->options['enabled_post_types'] ) ) {
245 Util::log_to_event_stream( 'empty_enabled_post_types', 'Did not send notification due to empty enabled_post_types.' );
246 return;
247 }
248
249 if ( ! in_array( $post->post_type, $this->options['enabled_post_types'], true ) ) {
250 if ( in_array( $new_status, [ 'publish', 'future' ], true ) ) {
251 Util::log_to_event_stream( 'disabled_post_type', "Did not send notification due to `{$post->post_type}` not being an enabled post type." );
252 }
253 return;
254 }
255
256 $this->on_should_save_notification( $post, $old_status, $new_status );
257 }
258
259 /**
260 * Registers the appropriate late-firing hook based on whether the request
261 * is a REST call (Gutenberg) or a classic form POST.
262 *
263 * rest_after_insert_{post_type} fires after WordPress has committed all meta to the DB.
264 * save_post fires after meta boxes have saved in the classic editor.
265 */
266 private function on_should_save_notification( \WP_Post $post, string $old_status, string $new_status ): void {
267 if ( $old_status === 'trash' ) {
268 return;
269 }
270
271 if ( ! in_array( $new_status, [ 'publish', 'future' ], true ) ) {
272 return;
273 }
274
275 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
276 add_action( "rest_after_insert_{$post->post_type}", [ $this, 'save_notification_from_post' ], 99, 2 );
277 } else {
278 $post_id = $post->ID;
279 add_action( 'save_post', function ( int $saved_post_id ) use ( $post_id ): void {
280 if ( $saved_post_id === $post_id ) {
281 $post = get_post( $saved_post_id );
282 if ( $post ) {
283 $this->save_notification_from_post( $post );
284 }
285 }
286 }, 999 );
287 }
288 }
289
290 public function save_notification_from_post( \WP_Post $post, ?\WP_REST_Request $request = null ): void {
291 $prefix = $this->build_log_prefix( $post );
292
293 try {
294 // Log send flow start
295 $this->log(
296 'info',
297 'send_flow_start',
298 "{$prefix}: Send flow started",
299 $post->ID
300 );
301
302 // Verify nonce for classic editor requests
303 if ( ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
304 $nonce = isset( $_POST['pushly_meta_box_nonce'] )
305 ? sanitize_text_field( wp_unslash( $_POST['pushly_meta_box_nonce'] ) )
306 : '';
307 if ( ! wp_verify_nonce( $nonce, 'pushly_save_notification_meta_box' ) ) {
308 Util::log_to_event_stream( 'nonce_verification_failed', 'Did not send notification due to nonce verification failure.' );
309 $this->log(
310 'warning',
311 'nonce_check',
312 "{$prefix}: Send skipped — nonce verification failed",
313 $post->ID
314 );
315 return;
316 }
317 }
318
319 // Transient lock prevents race conditions from concurrent requests
320 $lock_key = "pushly_sending_{$post->ID}";
321 if ( get_transient( $lock_key ) ) {
322 Util::log_to_event_stream( 'concurrent_request', 'Skipping notification send due to concurrent request already processing for post.' );
323 $this->log(
324 'warning',
325 'transient_lock',
326 "{$prefix}: Send skipped — concurrent request already processing",
327 $post->ID
328 );
329 return;
330 }
331 set_transient( $lock_key, true, 30 );
332
333 $meta = $this->get_post_meta( $post->ID, $request );
334
335 if ( empty( $meta['pushly_send_notification'] ) ) {
336 Util::log_to_event_stream( 'send_notification_status', 'Did not send notification due to false pushly_send_notification status.' );
337 $this->log(
338 'debug',
339 'meta_check',
340 "{$prefix}: Send skipped — pushly_send_notification meta is false",
341 $post->ID
342 );
343 delete_transient( $lock_key );
344 return;
345 }
346
347 $existing_notification_id = get_post_meta( $post->ID, 'pushly_notification_id', true );
348 if ( ! empty( $existing_notification_id ) ) {
349 Util::log_to_event_stream( 'post_already_sent', "Did not send notification due to notification already being sent for post ({$existing_notification_id})." );
350 $this->log(
351 'info',
352 'duplicate_check',
353 "{$prefix}: Send skipped — notification already sent (notification ID: {$existing_notification_id})",
354 $post->ID,
355 [ 'notification_id' => $existing_notification_id ]
356 );
357 delete_transient( $lock_key );
358 return;
359 }
360
361 if ( $post->post_status !== 'publish' ) {
362 Util::log_to_event_stream( 'invalid_post_status', "Did not send notification due to invalid post status ({$post->post_status})." );
363 $this->log(
364 'debug',
365 'post_status_check',
366 "{$prefix}: Send skipped — post status is '{$post->post_status}', expected 'publish'",
367 $post->ID,
368 [ 'post_status' => $post->post_status ]
369 );
370 delete_transient( $lock_key );
371 return;
372 }
373
374 // Resolve title and body
375 if ( ! empty( $meta['pushly_customize_notification_content'] ) && ! empty( $meta['pushly_custom_title'] ) ) {
376 $title = $meta['pushly_custom_title'];
377 $body = $meta['pushly_custom_body'] ?? null;
378 } else {
379 $title = $post->post_title;
380 $body = null;
381 }
382
383 // Resolve audience. Targeting and exclusions are independent: either may be
384 // left empty, so excluding segments without targeting any sends to all
385 // subscribers minus the excluded ones.
386 $notification_meta = [];
387 if ( ! empty( $meta['pushly_customize_audience'] ) ) {
388 if ( ! empty( $meta['pushly_audience_ids'] ) ) {
389 $notification_meta['segment_ids'] = $meta['pushly_audience_ids'];
390 }
391
392 if ( ! empty( $meta['pushly_excluded_audience_ids'] ) ) {
393 $notification_meta['excluded_segment_ids'] = $meta['pushly_excluded_audience_ids'];
394 }
395 }
396
397 $payload = [
398 'ID' => $post->ID,
399 'title' => stripslashes( wp_specialchars_decode( $title ) ),
400 'body' => $body ? stripslashes( wp_specialchars_decode( $body ) ) : null,
401 'landing_url' => get_permalink( $post->ID ),
402 'schedule_date' => $post->post_date_gmt,
403 'post_type' => $post->post_type,
404 'post_status' => $post->post_status,
405 'editor_type' => ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ? 'gutenberg' : 'classic',
406 ];
407
408 $tags = get_the_tags( $post->ID );
409 if ( ! empty( $tags ) ) {
410 $payload['tag_names'] = array_map( fn( $tag ) => $tag->name, $tags );
411 }
412
413 $categories = get_the_category( $post->ID );
414 if ( ! empty( $categories ) ) {
415 $payload['category_names'] = array_map( fn( $cat ) => $cat->name, $categories );
416 }
417
418 if ( has_post_thumbnail( $post->ID ) ) {
419 $payload['image_id'] = get_post_thumbnail_id( $post->ID );
420 }
421
422 $notification = Notification::from_post( $payload, $notification_meta );
423 if ( $notification ) {
424 $this->log(
425 'info',
426 'notification_build',
427 "{$prefix}: Notification built successfully",
428 $post->ID
429 );
430
431 $response = $this->api_save_notification( $notification, $post );
432 if ( ! empty( $response['id'] ) ) {
433 update_post_meta( $post->ID, 'pushly_notification_id', $response['id'] );
434 }
435 } else {
436 $this->log(
437 'error',
438 'notification_build',
439 "{$prefix}: Notification build failed — returned null",
440 $post->ID
441 );
442 }
443
444 delete_transient( $lock_key );
445 } catch ( \Exception $e ) {
446 Util::log_to_event_stream( 'unknown_exception', "Encountered unknown exception during send: {$e->getMessage()}" );
447 $this->log(
448 'error',
449 'send_exception',
450 "{$prefix}: Error — uncaught exception: {$e->getMessage()}",
451 $post->ID,
452 [ 'exception_class' => get_class( $e ), 'exception_message' => $e->getMessage() ]
453 );
454 delete_transient( "pushly_sending_{$post->ID}" );
455 }
456 }
457
458 // -------------------------------------------------------------------------
459 // Utilities
460 // -------------------------------------------------------------------------
461
462 /**
463 * Returns all pushly_ post meta for a given post, preferring the request body
464 * over the database. Gutenberg/REST sends meta in the JSON request body, and
465 * the database may not be up-to-date at the time save_notification_from_post
466 * runs. Falls back to the database for scheduled post transitions and other
467 * cases where the request body is empty.
468 *
469 * @return array<string, mixed>
470 */
471 protected function get_post_meta( int $post_id, ?\WP_REST_Request $request = null ): array {
472 $meta = [];
473
474 // REST API (Gutenberg): read meta from the WP_REST_Request object.
475 if ( $request !== null ) {
476 $request_meta = $request->get_param( 'meta' );
477 if ( is_array( $request_meta ) ) {
478 foreach ( $request_meta as $key => $value ) {
479 if ( str_starts_with( $key, 'pushly_' ) ) {
480 $meta[ $key ] = $value;
481 }
482 }
483 }
484 }
485
486 // Classic editor sends meta fields via $_POST alongside the nonce.
487 // Only read $_POST after verifying the meta-box nonce; otherwise the
488 // data is untrusted (and PluginCheck rightly flags an un-nonced read).
489 if ( empty( $meta ) && isset( $_POST['pushly_meta_box_nonce'] ) ) {
490 $nonce = sanitize_text_field( wp_unslash( $_POST['pushly_meta_box_nonce'] ) );
491 if ( wp_verify_nonce( $nonce, 'pushly_save_notification_meta_box' ) ) {
492 foreach ( $_POST as $key => $value ) {
493 if ( str_starts_with( $key, 'pushly_' ) && $key !== 'pushly_meta_box_nonce' ) {
494 $meta[ $key ] = is_string( $value )
495 ? sanitize_text_field( wp_unslash( $value ) )
496 : map_deep( wp_unslash( $value ), 'sanitize_text_field' );
497 }
498 }
499 }
500 }
501
502 // Fall back to the database using single-key lookups so that
503 // register_post_meta defaults are respected. The no-key form
504 // of get_post_meta() does NOT return defaults for missing rows.
505 if ( empty( $meta ) ) {
506 $keys = [
507 'pushly_send_notification',
508 'pushly_customize_notification_content',
509 'pushly_custom_title',
510 'pushly_custom_body',
511 'pushly_customize_audience',
512 'pushly_audience_ids',
513 'pushly_excluded_audience_ids',
514 ];
515
516 foreach ( $keys as $key ) {
517 $value = get_post_meta( $post_id, $key, true );
518 if ( $value !== '' && $value !== false ) {
519 $meta[ $key ] = $value;
520 }
521 }
522 }
523
524 if ( ! empty( $meta['pushly_customize_audience'] ) ) {
525 foreach ( [ 'pushly_audience_ids', 'pushly_excluded_audience_ids' ] as $audience_key ) {
526 if ( empty( $meta[ $audience_key ] ) || ! is_string( $meta[ $audience_key ] ) ) {
527 continue;
528 }
529
530 // allowed_classes => false prevents PHP object injection if the input
531 // is ever attacker-controlled (defense in depth; the value reaches
532 // here only after nonce verification or from get_post_meta).
533 $decoded = unserialize( $meta[ $audience_key ], [ 'allowed_classes' => false ] );
534 $meta[ $audience_key ] = is_array( $decoded ) ? $decoded : [];
535 }
536 }
537
538 return $meta;
539 }
540
541 protected function is_enabled_post_screen(): bool {
542 if ( empty( $this->options['enabled_post_types'] ) ) {
543 return false;
544 }
545
546 $screen = get_current_screen();
547 return $screen
548 && $screen->base === 'post'
549 && in_array( $screen->post_type, $this->options['enabled_post_types'], true );
550 }
551
552 protected function is_using_gutenberg(): bool {
553 if ( function_exists( 'is_gutenberg_page' ) && is_gutenberg_page() ) {
554 return true;
555 }
556
557 $screen = get_current_screen();
558 return $screen && method_exists( $screen, 'is_block_editor' ) && $screen->is_block_editor();
559 }
560
561 public function emit_notice(): void {
562 if ( $this->is_using_gutenberg() ) {
563 return;
564 }
565
566 $screen = get_current_screen();
567 if ( ! $screen || $screen->base !== 'post' ) {
568 return;
569 }
570
571 $message = get_transient( 'pushly_notice' );
572 if ( $message ) {
573 delete_transient( 'pushly_notice' );
574 printf(
575 '<div class="notice error is-dismissible"><p>Pushly Notifications: %s</p></div>',
576 esc_html( __( 'Failed to Send - ', 'pushly' ) . $message )
577 );
578 }
579 }
580
581 // -------------------------------------------------------------------------
582 // REST API
583 // -------------------------------------------------------------------------
584
585 public function register_api_routes(): void {
586 register_rest_route( 'pushly/v1', '/segments', [
587 'methods' => 'GET',
588 'permission_callback' => fn() => current_user_can( 'edit_posts' ),
589 'callback' => fn() => rest_ensure_response( $this->api_get_segments() ),
590 ] );
591 }
592
593 protected function api_get_segments(): array {
594 $segments = [];
595
596 try {
597 $options = Util::get_api_options();
598 if ( $options === null ) {
599 return $segments;
600 }
601
602 $api_key = Util::decrypt_api_key( $options['sdk_key'], $options['sdk_key'], $options['api_key'] );
603 $url = 'https://' . PUSHLY__API_DOMAIN . "/domains/{$options['domain_id']}/segments?pagination=0&source=standard&include_default=0&fields=id,name";
604 $response = wp_remote_get( $url, [ 'headers' => [ 'X-API-KEY' => $api_key ] ] );
605
606 if ( is_wp_error( $response ) ) {
607 Util::log_to_event_stream( 'segment_fetch_error', $response->get_error_message() );
608 } else {
609 $body = json_decode( $response['body'], true );
610 if ( ( $body['status'] ?? '' ) === 'success' ) {
611 $segments = $body;
612 } elseif ( ! empty( $body['message'] ) ) {
613 Util::log_to_event_stream( 'segment_fetch_error', $body['message'] );
614 }
615 }
616 } catch ( \Exception $e ) {
617 Util::log_to_event_stream( 'unknown_exception', "Encountered unknown exception during segment fetch: {$e->getMessage()}" );
618 }
619
620 return $segments;
621 }
622
623 protected function api_save_notification( Notification $notification, ?\WP_Post $post = null ): ?array {
624 /**
625 * Filters the notification before it is sent to the Pushly API.
626 * Return a non-null array to short-circuit the HTTP request (useful for testing).
627 *
628 * @param null|array $pre_response Return non-null to short-circuit.
629 * @param Notification $notification The notification about to be sent.
630 */
631 $pre = apply_filters( 'pushly_pre_send_notification', null, $notification );
632 if ( $pre !== null ) {
633 return $pre;
634 }
635
636 $options = Util::get_api_options();
637 if ( $options === null ) {
638 return null;
639 }
640
641 $api_key = Util::decrypt_api_key( $options['sdk_key'], $options['sdk_key'], $options['api_key'] );
642
643 if ( ! empty( $notification->id ) ) {
644 $url = 'https://' . PUSHLY__API_DOMAIN . "/domains/{$options['domain_id']}/notifications/{$notification->id}";
645 $method = 'PATCH';
646 $response = wp_remote_request( $url, [
647 'method' => 'PATCH',
648 'body' => wp_json_encode( $notification ),
649 'headers' => [ 'Content-Type' => 'application/json', 'X-API-KEY' => $api_key ],
650 ] );
651 } else {
652 $url = 'https://' . PUSHLY__API_DOMAIN . "/domains/{$options['domain_id']}/notifications";
653 $method = 'POST';
654 $response = wp_remote_post( $url, [
655 'body' => wp_json_encode( $notification ),
656 'headers' => [ 'Content-Type' => 'application/json', 'X-API-KEY' => $api_key ],
657 ] );
658 }
659
660 // Log API request dispatch
661 $prefix = ( $post !== null ) ? $this->build_log_prefix( $post ) : '';
662
663 if ( $post !== null ) {
664 $this->log(
665 'debug',
666 'api_request',
667 "{$prefix}: API request dispatched — {$method} {$url}",
668 $post->ID,
669 [ 'url' => $url, 'method' => $method ]
670 );
671 }
672
673 if ( is_wp_error( $response ) ) {
674 Util::log_to_event_stream( 'send_error', $response->get_error_message() );
675 if ( $post !== null ) {
676 $this->log(
677 'error',
678 'api_response',
679 "{$prefix}: API error — {$response->get_error_message()} (URL: {$url})",
680 $post->ID,
681 [ 'url' => $url, 'error' => $response->get_error_message() ]
682 );
683 }
684 return null;
685 }
686
687 $http_status = wp_remote_retrieve_response_code( $response );
688 $body = json_decode( $response['body'], true );
689
690 if ( ( $body['status'] ?? '' ) === 'success' ) {
691 if ( $post !== null ) {
692 $notification_id = $body['data']['id'] ?? null;
693 $this->log(
694 'info',
695 'api_response',
696 "{$prefix}: Notification created (notification ID: {$notification_id})",
697 $post->ID,
698 [ 'notification_id' => $notification_id, 'http_status' => $http_status ]
699 );
700 }
701 return $body['data'];
702 }
703
704 $error_message = $body['message'] ?? 'Unknown error';
705 if ( ! empty( $body['message'] ) ) {
706 Util::log_to_event_stream( 'send_error', $body['message'] );
707 }
708
709 if ( $post !== null ) {
710 $this->log(
711 'error',
712 'api_response',
713 "{$prefix}: API error — HTTP {$http_status}: \"{$error_message}\" (URL: {$url})",
714 $post->ID,
715 [ 'url' => $url, 'http_status' => $http_status, 'error_message' => $error_message ]
716 );
717 }
718
719 return null;
720 }
721 }
722