PluginProbe
WANotifier for Forms and Actions / 3.2.0
WANotifier for Forms and Actions v3.2.0
3.2.0 3.1.1 3.1.0 3.0.4 2.7.10 2.7.11 2.7.12 2.7.13 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 3.0.0 3.0.1 3.0.2 3.0.3 trunk 0.1.0 0.1.1 1.0.0 1.0.1 All 68 releases
notifier / includes / classes / class-notifier-backend.php

class-notifier-backend.php in WANotifier for Forms and Actions 3.2.0, at includes/classes/class-notifier-backend.php

1,212 lines 50.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) {
3 exit;
4 }
5
6 /**
7 * Backend class for WANotifier plugin v3
8 *
9 * Consolidates admin menu registration, settings management, REST API endpoints,
10 * and the React shell view. Replaces the former Dashboard, Settings, Tools, and
11 * WooCommerce Configure classes.
12 *
13 * @package Notifier
14 */
15 class Notifier_Backend {
16
17 // ========================================
18 // 1. INITIALIZATION
19 // ========================================
20
21 public static function init() {
22 add_action( 'admin_menu', array( __CLASS__, 'register_menus' ) );
23 add_action( 'admin_menu', array( __CLASS__, 'manage_menu_visibility' ), 999 );
24 add_action( 'admin_init', array( __CLASS__, 'handle_disconnect_action' ) );
25 add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
26 add_filter( 'plugin_action_links_notifier/notifier.php', array( __CLASS__, 'add_plugin_action_links' ) );
27 }
28
29 public static function add_plugin_action_links( $links ) {
30 $settings_link = '<a href="' . esc_url( admin_url( 'admin.php?page=notifier' ) ) . '">' . __( 'Settings', 'notifier' ) . '</a>';
31 array_unshift( $links, $settings_link );
32 return $links;
33 }
34
35 // ========================================
36 // 2. MENU REGISTRATION
37 // ========================================
38
39 public static function register_menus() {
40 add_menu_page(
41 'WANotifier',
42 'WANotifier',
43 'manage_options',
44 NOTIFIER_NAME,
45 array( __CLASS__, 'render_react_app' ),
46 'data:image/svg+xml;base64,' . base64_encode( file_get_contents( NOTIFIER_PATH . 'assets/images/menu-icon.svg' ) ),
47 '51'
48 );
49
50 add_submenu_page(
51 NOTIFIER_NAME,
52 __( 'Integrations', 'notifier' ),
53 __( 'Integrations', 'notifier' ),
54 'manage_options',
55 NOTIFIER_NAME,
56 array( __CLASS__, 'render_react_app' )
57 );
58
59 add_submenu_page(
60 NOTIFIER_NAME,
61 __( 'WhatsApp Chat Button', 'notifier' ),
62 __( 'Chat Button', 'notifier' ),
63 'manage_options',
64 'notifier-chat-button',
65 array( __CLASS__, 'render_react_app' )
66 );
67
68 add_submenu_page(
69 NOTIFIER_NAME,
70 __( 'Settings', 'notifier' ),
71 __( 'Settings', 'notifier' ),
72 'manage_options',
73 'notifier-settings',
74 array( __CLASS__, 'render_react_app' )
75 );
76
77 add_submenu_page(
78 NOTIFIER_NAME,
79 __( 'Logs', 'notifier' ),
80 __( 'Logs', 'notifier' ),
81 'manage_options',
82 'notifier-logs',
83 array( __CLASS__, 'render_react_app' )
84 );
85
86 add_submenu_page(
87 NOTIFIER_NAME,
88 __( 'Migrate to v3', 'notifier' ),
89 __( 'Migrate to v3', 'notifier' ),
90 'manage_options',
91 'notifier-migration',
92 array( __CLASS__, 'render_react_app' )
93 );
94 }
95
96 /**
97 * Manage submenu visibility based on application state.
98 */
99 public static function manage_menu_visibility() {
100 global $submenu;
101
102 if ( empty( $submenu[ NOTIFIER_NAME ] ) ) {
103 return;
104 }
105
106 $migration_complete = Notifier_Migration::is_migration_complete();
107 $has_legacy = Notifier_Migration::has_legacy_triggers();
108
109 if ( ! $migration_complete && $has_legacy ) {
110 // Migration pending: show only "Migrate to v3".
111 foreach ( $submenu[ NOTIFIER_NAME ] as $key => $item ) {
112 if ( isset( $item[2] ) && 'notifier-migration' !== $item[2] ) {
113 unset( $submenu[ NOTIFIER_NAME ][ $key ] );
114 }
115 }
116 } else {
117 // Migration complete or no legacy triggers: hide migration page.
118 foreach ( $submenu[ NOTIFIER_NAME ] as $key => $item ) {
119 if ( isset( $item[2] ) && 'notifier-migration' === $item[2] ) {
120 unset( $submenu[ NOTIFIER_NAME ][ $key ] );
121 }
122 }
123 }
124
125 // Always hide legacy trigger CPT menu items.
126 foreach ( $submenu[ NOTIFIER_NAME ] as $key => $item ) {
127 if ( isset( $item[2] ) && (
128 'edit.php?post_type=wa_notifier_trigger' === $item[2] ||
129 'post-new.php?post_type=wa_notifier_trigger' === $item[2]
130 ) ) {
131 unset( $submenu[ NOTIFIER_NAME ][ $key ] );
132 }
133 }
134 }
135
136 // ========================================
137 // 3. REACT SHELL VIEW
138 // ========================================
139
140 public static function render_react_app() {
141 include_once NOTIFIER_PATH . 'views/admin.php';
142 }
143
144 // ========================================
145 // 4. DISCONNECT HANDLER
146 // ========================================
147
148 public static function handle_disconnect_action() {
149 if ( ! isset( $_GET['notifier_disconnect'] ) || ! isset( $_GET['notifier_slug'] ) ) {
150 return;
151 }
152
153 if ( empty( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'notifier_disconnect_' . sanitize_key( $_GET['notifier_slug'] ) ) ) {
154 return;
155 }
156
157 if ( ! current_user_can( 'manage_options' ) ) {
158 return;
159 }
160
161 $slug = sanitize_key( $_GET['notifier_slug'] );
162 Notifier_Connection::disconnect( $slug );
163
164 wp_safe_redirect( admin_url( 'admin.php?page=notifier&disconnected=' . $slug ) );
165 exit;
166 }
167
168 // ========================================
169 // 5. REST API ROUTES
170 // ========================================
171
172 public static function register_rest_routes() {
173 $namespace = 'notifier/v1';
174
175 register_rest_route( $namespace, '/admin/integrations', array(
176 'methods' => 'GET',
177 'callback' => array( __CLASS__, 'rest_get_integrations' ),
178 'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
179 ) );
180
181 register_rest_route( $namespace, '/admin/settings', array(
182 array(
183 'methods' => 'GET',
184 'callback' => array( __CLASS__, 'rest_get_settings' ),
185 'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
186 ),
187 array(
188 'methods' => 'POST',
189 'callback' => array( __CLASS__, 'rest_save_settings' ),
190 'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
191 ),
192 ) );
193
194 register_rest_route( $namespace, '/admin/search-content', array(
195 'methods' => 'GET',
196 'callback' => array( __CLASS__, 'rest_search_content' ),
197 'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
198 ) );
199 register_rest_route( $namespace, '/admin/sync-triggers', array(
200 'methods' => 'POST',
201 'callback' => array( __CLASS__, 'rest_sync_triggers' ),
202 'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
203 ) );
204
205 register_rest_route( $namespace, '/admin/preview-btn-style', array(
206 'methods' => 'POST',
207 'callback' => array( __CLASS__, 'rest_preview_btn_style' ),
208 'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
209 ) );
210
211 register_rest_route( $namespace, '/admin/logs/dates', array(
212 'methods' => 'GET',
213 'callback' => array( __CLASS__, 'rest_get_log_dates' ),
214 'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
215 ) );
216
217 register_rest_route( $namespace, '/admin/logs', array(
218 array(
219 'methods' => 'GET',
220 'callback' => array( __CLASS__, 'rest_get_logs' ),
221 'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
222 ),
223 array(
224 'methods' => 'DELETE',
225 'callback' => array( __CLASS__, 'rest_delete_logs' ),
226 'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
227 ),
228 ) );
229
230 register_rest_route( $namespace, '/admin/migration-status', array(
231 'methods' => 'GET',
232 'callback' => array( __CLASS__, 'rest_get_migration_status' ),
233 'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
234 ) );
235
236 register_rest_route( $namespace, '/admin/finalize-migration', array(
237 'methods' => 'POST',
238 'callback' => array( __CLASS__, 'rest_finalize_migration' ),
239 'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
240 ) );
241
242 register_rest_route( $namespace, '/admin/disconnect', array(
243 'methods' => 'POST',
244 'callback' => array( __CLASS__, 'rest_disconnect' ),
245 'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
246 ) );
247
248 register_rest_route( $namespace, '/admin/wp-available-meta-fields', array(
249 'methods' => 'GET',
250 'callback' => array( __CLASS__, 'rest_get_wp_available_meta_fields' ),
251 'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
252 ) );
253 register_rest_route( $namespace, '/admin/sync-meta-fields', array(
254 'methods' => 'POST',
255 'callback' => array( __CLASS__, 'rest_sync_meta_fields' ),
256 'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
257 ) );
258 }
259
260 public static function rest_permission_check() {
261 return current_user_can( 'manage_options' );
262 }
263
264 // ========================================
265 // 6. REST HANDLERS — INTEGRATIONS
266 // ========================================
267
268 public static function rest_get_integrations() {
269 $integrations = self::get_integrations();
270 $result = array();
271
272 foreach ( $integrations as $slug => $integration ) {
273 $no_connect = ! empty( $integration['no_connect'] );
274 $is_connected = ! $no_connect && Notifier_Connection::is_connected( $slug );
275 $connect_url = ! $no_connect ? Notifier_Connection::get_connect_url( $slug ) : '';
276
277 $disconnect_url = wp_nonce_url(
278 admin_url( 'admin.php?page=notifier&notifier_disconnect=1&notifier_slug=' . $slug ),
279 'notifier_disconnect_' . $slug
280 );
281
282 $conn = $is_connected ? Notifier_Connection::get_connection_info( $slug ) : array();
283 $connected_at = ! empty( $conn['connected_at'] ) ? $conn['connected_at'] : '';
284
285 $result[ $slug ] = array_merge( $integration, array(
286 'slug' => $slug,
287 'is_connected' => $is_connected,
288 'connect_url' => $connect_url,
289 'disconnect_url' => $disconnect_url,
290 'connected_at' => $connected_at,
291 ) );
292 }
293
294 $result['_meta'] = array(
295 'has_woo_legacy_triggers' => ! Notifier_Migration::is_migration_complete() && in_array( Notifier_Migration::get_trigger_scenario(), array( 'woo_only', 'mixed' ), true ),
296 'is_woo_active' => class_exists( 'WooCommerce' ),
297 'is_wanowc_active' => Notifier::is_woo_handled_by_standalone_plugin(),
298 );
299
300 return new WP_REST_Response( $result, 200 );
301 }
302
303 // ========================================
304 // 7. REST HANDLERS — SETTINGS
305 // ========================================
306
307 public static function rest_get_settings() {
308 return new WP_REST_Response( self::get_all_settings(), 200 );
309 }
310
311 public static function rest_save_settings( WP_REST_Request $request ) {
312 $data = $request->get_json_params();
313 $errors = array();
314 $allowed = self::get_settings_field_definitions();
315
316 // The chat button can't be enabled without a WhatsApp number (it would silently not render).
317 $enabling_ctc = isset( $data['ctc_enable'] ) && ( 'yes' === $data['ctc_enable'] || '1' === $data['ctc_enable'] || true === $data['ctc_enable'] );
318 $ctc_number = isset( $data['ctc_whatsapp_number'] ) ? trim( (string) $data['ctc_whatsapp_number'] ) : get_option( 'notifier_ctc_whatsapp_number', '' );
319 if ( $enabling_ctc && '' === $ctc_number ) {
320 return new WP_REST_Response( array(
321 'success' => false,
322 'errors' => array( __( 'Please enter a WhatsApp number to enable the chat button.', 'notifier' ) ),
323 ), 200 );
324 }
325
326 foreach ( $data as $key => $value ) {
327 if ( ! isset( $allowed[ $key ] ) ) {
328 continue;
329 }
330
331 $def = $allowed[ $key ];
332 $option_name = NOTIFIER_PREFIX . $key;
333
334 switch ( $def['type'] ) {
335 case 'checkbox':
336 $value = ( 'yes' === $value || '1' === $value || true === $value ) ? 'yes' : 'no';
337 break;
338 case 'number':
339 $value = absint( $value );
340 if ( isset( $def['min'] ) ) {
341 $value = max( $def['min'], $value );
342 }
343 break;
344 case 'select':
345 if ( ! empty( $def['options'] ) && ! array_key_exists( $value, $def['options'] ) ) {
346 $value = $def['default'] ?? '';
347 }
348 break;
349 case 'textarea':
350 $value = wp_kses_post( trim( $value ) );
351 break;
352 case 'display_rules':
353 $value = self::sanitize_ctc_display_rules( is_array( $value ) ? $value : array() );
354 break;
355 case 'array':
356 $value = is_array( $value ) ? array_map( 'sanitize_text_field', $value ) : array();
357 if ( ! empty( $def['options'] ) ) {
358 $value = array_values( array_intersect( $value, array_keys( $def['options'] ) ) );
359 }
360 break;
361 default:
362 $value = sanitize_text_field( $value );
363 break;
364 }
365
366 if ( 'ctc_whatsapp_number' === $key && ! empty( $value ) && function_exists( 'notifier_validate_phone_number' ) && ! notifier_validate_phone_number( $value ) ) {
367 $errors[] = __( 'Please enter a valid WhatsApp phone number with country code.', 'notifier' );
368 continue;
369 }
370
371 update_option( $option_name, $value, 'yes' );
372 }
373
374 if ( ! empty( $errors ) ) {
375 return new WP_REST_Response( array(
376 'success' => false,
377 'errors' => $errors,
378 ), 200 );
379 }
380
381 return new WP_REST_Response( array(
382 'success' => true,
383 'message' => __( 'Settings saved.', 'notifier' ),
384 'settings' => self::get_all_settings(),
385 ), 200 );
386 }
387
388 // ========================================
389 // 8. REST HANDLERS — SYNC TRIGGERS
390 // ========================================
391
392 public static function rest_sync_triggers( WP_REST_Request $request ) {
393 $slug = sanitize_key( $request->get_param( 'slug' ) );
394
395 if ( empty( $slug ) || ! Notifier_Connection::is_connected( $slug ) ) {
396 return new WP_REST_Response( array(
397 'success' => false,
398 'message' => __( 'Integration not connected.', 'notifier' ),
399 ), 400 );
400 }
401
402 $trigger_schema = Notifier_Connection::build_trigger_schema( $slug );
403 $result = Notifier_Connection::send_saas_request( $slug, 'sync', array( 'triggers' => $trigger_schema ) );
404
405 return new WP_REST_Response( array(
406 'success' => ( false !== $result ),
407 'message' => ( false !== $result ) ? __( 'Triggers synced successfully.', 'notifier' ) : __( 'Failed to sync with SaaS.', 'notifier' ),
408 ), 200 );
409 }
410
411 public static function rest_disconnect( WP_REST_Request $request ) {
412 $data = $request->get_json_params();
413 $slug = isset( $data['slug'] ) ? sanitize_key( $data['slug'] ) : '';
414
415 if ( empty( $slug ) ) {
416 return new WP_REST_Response( array( 'success' => false, 'message' => __( 'Invalid slug.', 'notifier' ) ), 400 );
417 }
418
419 Notifier_Connection::disconnect( $slug );
420
421 return new WP_REST_Response( array( 'success' => true, 'message' => __( 'Disconnected successfully.', 'notifier' ) ), 200 );
422 }
423
424 // ========================================
425 // 9. REST HANDLERS — CHAT BUTTON PREVIEW
426 // ========================================
427
428 public static function rest_preview_btn_style( WP_REST_Request $request ) {
429 $btn_style = sanitize_text_field( $request->get_param( 'btn_style' ) );
430 $valid_styles = array_keys( self::get_button_styles() );
431
432 if ( ! in_array( $btn_style, $valid_styles, true ) || 'default' === $btn_style ) {
433 return new WP_REST_Response( array( 'preview' => '' ), 200 );
434 }
435
436 $template = NOTIFIER_PATH . 'templates/buttons/' . $btn_style . '.php';
437 if ( ! file_exists( $template ) ) {
438 return new WP_REST_Response( array( 'preview' => '' ), 200 );
439 }
440
441 ob_start();
442 include $template;
443 $html = ob_get_clean();
444
445 return new WP_REST_Response( array( 'preview' => $html ), 200 );
446 }
447
448 // ========================================
449 // 10. REST HANDLERS — LOGS
450 // ========================================
451
452 public static function rest_get_log_dates() {
453 global $wpdb;
454 $table_name = $wpdb->prefix . NOTIFIER_ACTIVITY_TABLE_NAME;
455
456 $dates = $wpdb->get_col( "SELECT DISTINCT DATE(timestamp) as log_date FROM `$table_name` ORDER BY log_date DESC" );
457
458 $formatted = array();
459 foreach ( $dates as $date ) {
460 $formatted[] = function_exists( 'notifier_convert_date_from_utc' )
461 ? notifier_convert_date_from_utc( $date, 'Y-m-d' )
462 : $date;
463 }
464
465 return new WP_REST_Response( $formatted, 200 );
466 }
467
468 public static function rest_get_logs( WP_REST_Request $request ) {
469 $date = sanitize_text_field( $request->get_param( 'date' ) );
470
471 if ( empty( $date ) ) {
472 return new WP_REST_Response( array(), 200 );
473 }
474
475 global $wpdb;
476 $table_name = $wpdb->prefix . NOTIFIER_ACTIVITY_TABLE_NAME;
477
478 $logs = $wpdb->get_results( $wpdb->prepare(
479 "SELECT log_id, timestamp, message, type FROM `$table_name` WHERE DATE(timestamp) = %s ORDER BY timestamp DESC",
480 $date
481 ) );
482
483 return new WP_REST_Response( $logs ?: array(), 200 );
484 }
485
486 public static function rest_delete_logs() {
487 global $wpdb;
488 $table_name = $wpdb->prefix . NOTIFIER_ACTIVITY_TABLE_NAME;
489
490 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table; $table_name from trusted constant and $wpdb->prefix only.
491 $wpdb->query( "TRUNCATE TABLE `{$table_name}`" );
492
493 return new WP_REST_Response( array(
494 'success' => true,
495 'message' => __( 'All logs have been deleted.', 'notifier' ),
496 ), 200 );
497 }
498
499 // ========================================
500 // 11. REST HANDLERS — MIGRATION
501 // ========================================
502
503 public static function rest_get_migration_status() {
504 $is_complete = Notifier_Migration::is_migration_complete();
505 $has_legacy = Notifier_Migration::has_legacy_triggers();
506 $slugs = Notifier_Migration::get_non_woo_slugs_with_legacy_triggers();
507 $all_connected = Notifier_Migration::all_non_woo_slugs_connected();
508 $migrate_url = Notifier_Migration::get_migrate_connect_url();
509 $trigger_scenario = Notifier_Migration::get_trigger_scenario();
510
511 $trigger_posts = array();
512 if ( $has_legacy ) {
513 $integrations = self::get_integrations();
514
515 $posts = get_posts( array(
516 'post_type' => 'wa_notifier_trigger',
517 'post_status' => 'publish',
518 'numberposts' => -1,
519 ) );
520
521 $all_triggers = Notifier_Notification_Triggers::get_notification_triggers();
522 $trigger_label_map = array();
523 foreach ( $all_triggers as $group => $triggers ) {
524 foreach ( $triggers as $t ) {
525 $base = Notifier_Notification_Triggers::get_trigger_id_without_site_key( $t['id'] );
526 $trigger_label_map[ $base ] = $t['label'];
527 }
528 }
529
530 foreach ( $posts as $post ) {
531 $trigger_id = get_post_meta( $post->ID, NOTIFIER_PREFIX . 'trigger', true );
532 $base_trigger = Notifier_Notification_Triggers::get_trigger_id_without_site_key( $trigger_id );
533 $slug = Notifier_Notification_Triggers::get_integration_slug_for_trigger( $base_trigger );
534 $is_woo = in_array( $slug, Notifier_Migration::WOO_SLUGS, true );
535 $connected = $slug ? Notifier_Connection::is_connected( $slug ) : false;
536 $label = isset( $integrations[ $slug ]['label'] ) ? $integrations[ $slug ]['label'] : ucfirst( $slug ?: 'Unknown' );
537 $trigger_label = isset( $trigger_label_map[ $base_trigger ] ) ? $trigger_label_map[ $base_trigger ] : $base_trigger;
538
539 $trigger_posts[] = array(
540 'id' => $post->ID,
541 'title' => $trigger_label,
542 'slug' => $slug ?: 'unknown',
543 'label' => $label,
544 'is_connected' => $connected,
545 'is_woo' => $is_woo,
546 );
547 }
548 }
549
550 return new WP_REST_Response( array(
551 'is_complete' => $is_complete,
552 'has_legacy' => $has_legacy,
553 'slugs' => $slugs,
554 'all_connected' => $all_connected,
555 'migrate_url' => $migrate_url,
556 'trigger_posts' => $trigger_posts,
557 'trigger_scenario' => $trigger_scenario,
558 'integrations_url' => admin_url( 'admin.php?page=notifier' ),
559 ), 200 );
560 }
561
562 public static function rest_finalize_migration() {
563 if ( ! Notifier_Migration::all_non_woo_slugs_connected() ) {
564 return new WP_REST_Response( array(
565 'success' => false,
566 'message' => __( 'Not all integrations are connected yet.', 'notifier' ),
567 ), 400 );
568 }
569
570 $api_key = trim( get_option( 'notifier_api_key', '' ) );
571 if ( $api_key ) {
572 Notifier_Migration::disconnect_legacy_on_saas( $api_key );
573 }
574
575 Notifier_Migration::delete_legacy_trigger_posts();
576
577 update_option( Notifier_Migration::STATUS_OPTION, 'complete' );
578
579 return new WP_REST_Response( array(
580 'success' => true,
581 'message' => __( 'Migration complete!', 'notifier' ),
582 'redirect' => admin_url( 'admin.php?page=notifier' ),
583 ), 200 );
584 }
585
586
587 // ========================================
588 // 12b. REST HANDLER — WORDPRESS META FIELDS
589 // ========================================
590
591 public static function rest_get_wp_available_meta_fields( $request ) {
592 $show_hidden = rest_sanitize_boolean( $request->get_param( 'show_hidden' ) );
593 $groups = array();
594
595 // Single "Post Meta" group: all custom meta keys from all public post types (option stores all; filter by show_hidden).
596 $combined = Notifier_Notification_Merge_Tags::get_custom_meta_keys( null );
597 if ( ! empty( $combined ) ) {
598 $post_meta = array();
599 foreach ( $combined as $item ) {
600 if ( ! $show_hidden && isset( $item['key'] ) && substr( (string) $item['key'], 0, 1 ) === '_' ) {
601 continue;
602 }
603 $post_meta[] = array(
604 'id' => $item['post_type'] . '_meta_' . $item['key'],
605 'label' => $item['key'],
606 'return_type' => 'all',
607 );
608 }
609 if ( ! empty( $post_meta ) ) {
610 $groups['Post Meta'] = $post_meta;
611 }
612 }
613
614 // User meta group (option stores all; filter by show_hidden).
615 $user_keys = Notifier_Notification_Merge_Tags::get_user_meta_keys();
616 if ( ! empty( $user_keys ) && is_array( $user_keys ) ) {
617 $user_meta = array();
618 foreach ( $user_keys as $key ) {
619 if ( ! $show_hidden && substr( (string) $key, 0, 1 ) === '_' ) {
620 continue;
621 }
622 $user_meta[] = array(
623 'id' => 'user_meta_' . $key,
624 'label' => $key,
625 'return_type' => 'all',
626 );
627 }
628 if ( ! empty( $user_meta ) ) {
629 $groups['User Meta'] = $user_meta;
630 }
631 }
632
633 // Hook-added fields: exclude groups that are builtin (from merge tags / trigger definitions).
634 $hook_fields = self::get_hook_added_merge_tag_fields();
635 foreach ( $hook_fields as $field ) {
636 $group = isset( $field['group'] ) ? $field['group'] : 'Custom Fields';
637 if ( ! isset( $groups[ $group ] ) ) {
638 $groups[ $group ] = array();
639 }
640 $groups[ $group ][] = array(
641 'id' => $field['id'],
642 'label' => isset( $field['label'] ) ? $field['label'] : $field['id'],
643 'return_type' => isset( $field['return_type'] ) ? $field['return_type'] : 'text',
644 );
645 }
646
647 return new WP_REST_Response( array( 'groups' => $groups ), 200 );
648 }
649
650 /**
651 * POST /admin/sync-meta-fields — Sync all post and user meta keys from DB into options (normal + hidden).
652 * Call this to refresh the meta field list when new meta is added on the site.
653 */
654 public static function rest_sync_meta_fields() {
655 delete_option( 'notifier_custom_meta_keys' );
656 delete_option( 'notifier_user_meta_keys' );
657 Notifier_Notification_Merge_Tags::get_custom_meta_keys( null );
658 Notifier_Notification_Merge_Tags::get_user_meta_keys();
659 return new WP_REST_Response( array(
660 'success' => true,
661 'message' => __( 'Meta fields synced. The list now includes all current post and user meta keys.', 'notifier' ),
662 ), 200 );
663 }
664
665 /**
666 * Return merge tag fields added via the notifier_notification_merge_tags filter
667 * that are not part of the built-in groups.
668 *
669 * @return array[] Array of { group, id, label } objects.
670 */
671 private static function get_hook_added_merge_tag_fields() {
672 // Builtin groups from merge tags / trigger definitions so we exclude them from the Data Fields dropdown.
673 $all_tags = apply_filters( 'notifier_notification_merge_tags', array() );
674 $builtin_groups = array_merge( array( 'WordPress' ), array_keys( $all_tags ) );
675 $fields = array();
676
677 foreach ( $all_tags as $group => $tags ) {
678 if ( in_array( $group, $builtin_groups, true ) ) {
679 continue;
680 }
681 foreach ( (array) $tags as $tag ) {
682 if ( empty( $tag['id'] ) ) {
683 continue;
684 }
685 $fields[] = array(
686 'group' => $group,
687 'id' => $tag['id'],
688 'label' => isset( $tag['label'] ) ? $tag['label'] : $tag['id'],
689 );
690 }
691 }
692
693 return $fields;
694 }
695
696 // ========================================
697 // 13. INTEGRATION DEFINITIONS
698 // ========================================
699
700 public static function get_integrations() {
701 $img = NOTIFIER_URL . 'assets/images/integrations/';
702 $woo_handled = Notifier::is_woo_handled_by_standalone_plugin();
703
704 return array(
705 'woocommerce' => array(
706 'label' => 'WooCommerce',
707 'description' => __( 'Order notifications and customer sync', 'notifier' ),
708 'icon' => $img . 'woocommerce-logo.png',
709 'active' => class_exists( 'WooCommerce' ),
710 'has_config' => $woo_handled ? false : true,
711 'config_component' => $woo_handled ? '' : 'WooCommerceConfig',
712 'handled_by_standalone' => $woo_handled,
713 ),
714 'wcar' => array(
715 'label' => 'Cart Abandonment Recovery for WooCommerce',
716 'description' => __( 'Send notifications when a cart is abandoned', 'notifier' ),
717 'icon' => $img . 'wcar-logo.svg',
718 'active' => class_exists( 'CARTFLOWS_CA_Loader' ) && defined( 'CARTFLOWS_CA_VER' ) && version_compare( CARTFLOWS_CA_VER, '1.2.25', '>=' ),
719 'has_config' => false,
720 'handled_by_standalone' => $woo_handled,
721 ),
722 'wordpress' => array(
723 'label' => 'WordPress',
724 'description' => __( 'Posts, users, comments and more', 'notifier' ),
725 'icon' => $img . 'wordpress-logo.png',
726 'active' => true,
727 'has_config' => true,
728 'config_component' => 'WordPressConfig',
729 ),
730 'gravityforms' => array(
731 'label' => 'Gravity Forms',
732 'description' => __( 'Form submission notifications', 'notifier' ),
733 'icon' => $img . 'gravity-forms-logo.png',
734 'active' => class_exists( 'GFCommon' ),
735 'has_config' => false,
736 ),
737 'cf7' => array(
738 'label' => 'Contact Form 7',
739 'description' => __( 'Form submission notifications', 'notifier' ),
740 'icon' => $img . 'contact-form-7-logo.png',
741 'active' => class_exists( 'WPCF7' ),
742 'has_config' => false,
743 ),
744 'wpforms' => array(
745 'label' => 'WPForms',
746 'description' => __( 'Form submission notifications', 'notifier' ),
747 'icon' => $img . 'wpforms-logo.png',
748 'active' => class_exists( 'WPForms' ),
749 'has_config' => false,
750 ),
751 'ninjaforms' => array(
752 'label' => 'Ninja Forms',
753 'description' => __( 'Form submission notifications', 'notifier' ),
754 'icon' => $img . 'nf-logo.png',
755 'active' => class_exists( 'Ninja_Forms' ),
756 'has_config' => false,
757 ),
758 'formidable' => array(
759 'label' => 'Formidable Forms',
760 'description' => __( 'Form submission notifications', 'notifier' ),
761 'icon' => $img . 'formidable-logo.png',
762 'active' => class_exists( 'FrmForm' ),
763 'has_config' => false,
764 ),
765 'fluentforms' => array(
766 'label' => 'Fluent Forms',
767 'description' => __( 'Form submission notifications', 'notifier' ),
768 'icon' => $img . 'fluent-forms-logo.webp',
769 'active' => function_exists( 'fluentFormApi' ),
770 'has_config' => false,
771 ),
772 'forminator' => array(
773 'label' => 'Forminator Forms',
774 'description' => __( 'Form submission notifications', 'notifier' ),
775 'icon' => $img . 'forminator-form-logo.svg',
776 'active' => class_exists( 'Forminator' ),
777 'has_config' => false,
778 ),
779 'sureforms' => array(
780 'label' => 'SureForms',
781 'description' => __( 'Form submission notifications', 'notifier' ),
782 'icon' => $img . 'sureform-logo.svg',
783 'active' => class_exists( 'SRFM\Plugin_Loader' ),
784 'has_config' => false,
785 ),
786 'wsform' => array(
787 'label' => 'WS Forms',
788 'description' => __( 'Form submission notifications', 'notifier' ),
789 'icon' => $img . 'ws-form-logo.svg',
790 'active' => class_exists( 'WS_Form' ),
791 'has_config' => false,
792 ),
793 );
794 }
795
796 // ========================================
797 // 14. SETTINGS DATA
798 // ========================================
799
800 public static function get_all_settings() {
801 return array(
802 'default_country_code' => get_option( 'notifier_default_country_code', '' ),
803 'disable_background_processing' => get_option( 'notifier_disable_background_processing', 'no' ),
804 'ctc_enable' => get_option( 'notifier_ctc_enable', 'no' ),
805 'ctc_whatsapp_number' => get_option( 'notifier_ctc_whatsapp_number', '' ),
806 'ctc_greeting_text' => get_option( 'notifier_ctc_greeting_text', '' ),
807 'ctc_button_style' => get_option( 'notifier_ctc_button_style', 'default' ),
808 'ctc_custom_button_image_url' => get_option( 'notifier_ctc_custom_button_image_url', '' ),
809 'ctc_position' => get_option( 'notifier_ctc_position', 'bottom-right' ),
810 'ctc_offset_x' => get_option( 'notifier_ctc_offset_x', 20 ),
811 'ctc_offset_y' => get_option( 'notifier_ctc_offset_y', 20 ),
812 'ctc_mobile_position_enable' => get_option( 'notifier_ctc_mobile_position_enable', 'no' ),
813 'ctc_mobile_position' => get_option( 'notifier_ctc_mobile_position', 'bottom-right' ),
814 'ctc_mobile_offset_x' => get_option( 'notifier_ctc_mobile_offset_x', 20 ),
815 'ctc_mobile_offset_y' => get_option( 'notifier_ctc_mobile_offset_y', 20 ),
816 'ctc_display_rules' => self::get_ctc_display_rules_for_ui(),
817 'ctc_devices' => get_option( 'notifier_ctc_devices', array_keys( self::get_ctc_device_options() ) ),
818 'enable_activity_log' => get_option( 'notifier_enable_activity_log', 'no' ),
819 'wp_enabled_merge_tags' => get_option( 'notifier_wp_enabled_merge_tags', array() ),
820 'wp_enabled_recipient_fields' => get_option( 'notifier_wp_enabled_recipient_fields', array() ),
821 'button_styles' => self::get_button_styles(),
822 'ctc_position_options' => self::get_ctc_position_options(),
823 'ctc_location_options' => self::get_ctc_location_options(),
824 'ctc_device_options' => self::get_ctc_device_options(),
825 );
826 }
827
828 private static function get_settings_field_definitions() {
829 return array(
830 'default_country_code' => array( 'type' => 'text' ),
831 'disable_background_processing' => array( 'type' => 'checkbox' ),
832 'ctc_enable' => array( 'type' => 'checkbox' ),
833 'ctc_whatsapp_number' => array( 'type' => 'text' ),
834 'ctc_greeting_text' => array( 'type' => 'text' ),
835 'ctc_button_style' => array( 'type' => 'select', 'options' => self::get_button_styles(), 'default' => 'default' ),
836 'ctc_custom_button_image_url' => array( 'type' => 'text' ),
837 'ctc_position' => array( 'type' => 'select', 'options' => self::get_ctc_position_options(), 'default' => 'bottom-right' ),
838 'ctc_offset_x' => array( 'type' => 'number', 'min' => 0 ),
839 'ctc_offset_y' => array( 'type' => 'number', 'min' => 0 ),
840 'ctc_mobile_position_enable' => array( 'type' => 'checkbox' ),
841 'ctc_mobile_position' => array( 'type' => 'select', 'options' => self::get_ctc_position_options(), 'default' => 'bottom-right' ),
842 'ctc_mobile_offset_x' => array( 'type' => 'number', 'min' => 0 ),
843 'ctc_mobile_offset_y' => array( 'type' => 'number', 'min' => 0 ),
844 'ctc_display_rules' => array( 'type' => 'display_rules' ),
845 'ctc_devices' => array( 'type' => 'array', 'options' => self::get_ctc_device_options() ),
846 'enable_activity_log' => array( 'type' => 'checkbox' ),
847 'wp_enabled_merge_tags' => array( 'type' => 'array' ),
848 'wp_enabled_recipient_fields' => array( 'type' => 'array' ),
849 );
850 }
851
852 public static function get_button_styles() {
853 return array(
854 'default' => 'Select button style',
855 'btn-style-1' => 'Style 1',
856 'btn-style-2' => 'Style 2',
857 'btn-style-3' => 'Style 3',
858 'btn-style-4' => 'Style 4',
859 'btn-custom-image' => 'Add your own image',
860 );
861 }
862
863 public static function get_ctc_position_options() {
864 return array(
865 'bottom-right' => 'Bottom Right',
866 'bottom-left' => 'Bottom Left',
867 'top-right' => 'Top Right',
868 'top-left' => 'Top Left',
869 );
870 }
871
872 // ----------------------------------------
873 // Chat button display rules
874 // ----------------------------------------
875
876 /**
877 * Grouped location options for the display / exclude rule dropdowns (react-select format).
878 *
879 * Rule keys:
880 * basic-global | basic-singulars | basic-archives
881 * special-front | special-blog | special-404 | special-search | special-date | special-author
882 * {post_type}|all | {post_type}|archive | {post_type}|tax|{taxonomy}
883 * wc-shop | wc-cart | wc-checkout | wc-account | wc-order-received
884 * specifics (with items: post-{id}, tax-{term_id}, tax-{term_id}-posts)
885 */
886 public static function get_ctc_location_options() {
887 $groups = array(
888 array(
889 'label' => 'Basic',
890 'options' => array(
891 array('value' => 'basic-global', 'label' => 'Entire Website'),
892 array('value' => 'basic-singulars', 'label' => 'All Singulars'),
893 array('value' => 'basic-archives', 'label' => 'All Archives'),
894 ),
895 ),
896 array(
897 'label' => 'Special Pages',
898 'options' => array(
899 array('value' => 'special-front', 'label' => 'Front Page'),
900 array('value' => 'special-blog', 'label' => 'Blog / Posts Page'),
901 array('value' => 'special-404', 'label' => '404 Page'),
902 array('value' => 'special-search', 'label' => 'Search Results'),
903 array('value' => 'special-date', 'label' => 'Date Archive'),
904 array('value' => 'special-author', 'label' => 'Author Archive'),
905 ),
906 ),
907 );
908
909 $has_woo = class_exists('WooCommerce');
910
911 foreach (self::get_ctc_rule_post_types() as $post_type) {
912 $options = array(
913 array('value' => $post_type->name . '|all', 'label' => 'All ' . $post_type->labels->name),
914 );
915
916 // The product archive is the WooCommerce shop page, listed under WooCommerce below.
917 if ($post_type->has_archive && ! ($has_woo && 'product' === $post_type->name)) {
918 $options[] = array('value' => $post_type->name . '|archive', 'label' => $post_type->labels->name . ' Archive');
919 }
920
921 foreach (get_object_taxonomies($post_type->name, 'objects') as $taxonomy) {
922 if (! self::is_ctc_rule_taxonomy($taxonomy)) {
923 continue;
924 }
925 $options[] = array(
926 'value' => $post_type->name . '|tax|' . $taxonomy->name,
927 'label' => 'All ' . $taxonomy->labels->singular_name . ' Archives',
928 );
929 }
930
931 $groups[] = array('label' => $post_type->labels->name, 'options' => $options);
932 }
933
934 if ($has_woo) {
935 $groups[] = array(
936 'label' => 'WooCommerce',
937 'options' => array(
938 array('value' => 'wc-shop', 'label' => 'Shop Page'),
939 array('value' => 'wc-cart', 'label' => 'Cart Page'),
940 array('value' => 'wc-checkout', 'label' => 'Checkout Page'),
941 array('value' => 'wc-account', 'label' => 'My Account Page'),
942 array('value' => 'wc-order-received', 'label' => 'Order Received (Thank You) Page'),
943 ),
944 );
945 }
946
947 $groups[] = array(
948 'label' => 'Specific Target',
949 'options' => array(
950 array('value' => 'specifics', 'label' => 'Specific Pages / Posts / Taxonomies'),
951 ),
952 );
953
954 return $groups;
955 }
956
957 /**
958 * Flat list of valid rule keys.
959 */
960 public static function get_ctc_location_keys() {
961 $keys = array();
962 foreach (self::get_ctc_location_options() as $group) {
963 foreach ($group['options'] as $option) {
964 $keys[] = $option['value'];
965 }
966 }
967 return $keys;
968 }
969
970 /**
971 * Public, frontend-viewable post types that rules can target.
972 *
973 * @return WP_Post_Type[]
974 */
975 public static function get_ctc_rule_post_types() {
976 $post_types = array();
977 foreach (get_post_types(array('public' => true), 'objects') as $post_type) {
978 if ('attachment' === $post_type->name || ! is_post_type_viewable($post_type)) {
979 continue;
980 }
981 $post_types[] = $post_type;
982 }
983 return $post_types;
984 }
985
986 /**
987 * Whether a taxonomy has frontend archives worth targeting.
988 *
989 * @param WP_Taxonomy $taxonomy
990 */
991 public static function is_ctc_rule_taxonomy($taxonomy) {
992 // Post formats and WooCommerce shipping classes are queryable but have no real archive pages.
993 if (in_array($taxonomy->name, array('post_format', 'product_shipping_class'), true)) {
994 return false;
995 }
996 return is_taxonomy_viewable($taxonomy);
997 }
998
999 public static function get_default_ctc_display_rules() {
1000 return array(
1001 'display' => array(array('rule' => 'basic-global', 'specifics' => array())),
1002 'exclude' => array(),
1003 );
1004 }
1005
1006 /**
1007 * Validate submitted rules: unknown rule keys and malformed specific items are dropped,
1008 * as are "Specific" rows with nothing selected.
1009 */
1010 public static function sanitize_ctc_display_rules($value) {
1011 $valid = self::get_ctc_location_keys();
1012 $clean = array('display' => array(), 'exclude' => array());
1013
1014 foreach (array('display', 'exclude') as $list) {
1015 if (empty($value[$list]) || ! is_array($value[$list])) {
1016 continue;
1017 }
1018 foreach ($value[$list] as $row) {
1019 $rule = isset($row['rule']) ? sanitize_text_field((string) $row['rule']) : '';
1020 if (! in_array($rule, $valid, true)) {
1021 continue;
1022 }
1023
1024 $specifics = array();
1025 if ('specifics' === $rule) {
1026 foreach ((array) ($row['specifics'] ?? array()) as $item) {
1027 $item = is_array($item) ? ($item['value'] ?? '') : $item;
1028 if (preg_match('/^(post|tax)-\d+(-posts)?$/', (string) $item)) {
1029 $specifics[] = (string) $item;
1030 }
1031 }
1032 if (empty($specifics)) {
1033 continue;
1034 }
1035 }
1036
1037 $clean[$list][] = array('rule' => $rule, 'specifics' => array_values(array_unique($specifics)));
1038 }
1039 }
1040
1041 return $clean;
1042 }
1043
1044 /**
1045 * Saved rules (raw keys), falling back to "Entire Website".
1046 */
1047 public static function get_ctc_display_rules() {
1048 $rules = get_option('notifier_ctc_display_rules', null);
1049 if (! is_array($rules) || ! isset($rules['display'])) {
1050 return self::get_default_ctc_display_rules();
1051 }
1052 return wp_parse_args($rules, array('display' => array(), 'exclude' => array()));
1053 }
1054
1055 /**
1056 * Saved rules with the specific items expanded to {value,label} options for the settings UI.
1057 * Items whose post/term no longer exists are left out.
1058 */
1059 public static function get_ctc_display_rules_for_ui() {
1060 $rules = self::get_ctc_display_rules();
1061 foreach ($rules as &$rows) {
1062 foreach ($rows as &$row) {
1063 $row['specifics'] = array_values(array_filter(array_map(array(__CLASS__, 'get_ctc_specific_option'), (array) $row['specifics'])));
1064 }
1065 unset($row);
1066 }
1067 unset($rows);
1068 return $rules;
1069 }
1070
1071 /**
1072 * Build the {value,label} option for a specific item key (post-{id}, tax-{id}, tax-{id}-posts).
1073 *
1074 * @return array|null Null if the post/term no longer exists.
1075 */
1076 public static function get_ctc_specific_option($key) {
1077 if (! preg_match('/^(post|tax)-(\d+)(-posts)?$/', (string) $key, $m)) {
1078 return null;
1079 }
1080 $id = (int) $m[2];
1081
1082 if ('post' === $m[1]) {
1083 $post = get_post($id);
1084 if (! $post || 'trash' === $post->post_status) {
1085 return null;
1086 }
1087 $type = get_post_type_object($post->post_type);
1088 // Plain text: React renders labels as text nodes, so entities from wptexturize/kses would show literally.
1089 $title = trim(html_entity_decode(wp_strip_all_tags($post->post_title), ENT_QUOTES, 'UTF-8'));
1090 return array(
1091 'value' => $key,
1092 'label' => ('' !== $title ? $title : '#' . $id) . ' (' . ($type ? $type->labels->singular_name : $post->post_type) . ')',
1093 );
1094 }
1095
1096 $term = get_term($id);
1097 if (! $term || is_wp_error($term)) {
1098 return null;
1099 }
1100 $taxonomy = get_taxonomy($term->taxonomy);
1101 $tax_label = $taxonomy ? $taxonomy->labels->singular_name : $term->taxonomy;
1102 $term_name = html_entity_decode($term->name, ENT_QUOTES, 'UTF-8');
1103
1104 return array(
1105 'value' => $key,
1106 'label' => ! empty($m[3])
1107 ? sprintf('%s (%s) – all items in it', $term_name, $tax_label)
1108 : sprintf('%s (%s) – archive page', $term_name, $tax_label),
1109 );
1110 }
1111
1112 /**
1113 * Search posts (all public post types) and terms (all public taxonomies) for the
1114 * "Specific Pages / Posts / Taxonomies" picker. Returns react-select option groups.
1115 */
1116 public static function rest_search_content(WP_REST_Request $request) {
1117 $query = trim(sanitize_text_field((string) $request->get_param('q')));
1118 $groups = array();
1119
1120 foreach (self::get_ctc_rule_post_types() as $post_type) {
1121 $args = array(
1122 'post_type' => $post_type->name,
1123 'post_status' => 'publish',
1124 'posts_per_page' => 10,
1125 'orderby' => '' !== $query ? 'relevance' : 'title',
1126 'order' => 'ASC',
1127 'fields' => 'ids',
1128 'no_found_rows' => true,
1129 );
1130 if ('' !== $query) {
1131 $args['s'] = $query;
1132 }
1133 $ids = get_posts($args);
1134
1135 // Allow pasting an ID directly (published items only, like the search results).
1136 if (is_numeric($query) && get_post_type((int) $query) === $post_type->name && 'publish' === get_post_status((int) $query)) {
1137 array_unshift($ids, (int) $query);
1138 }
1139
1140 $options = array();
1141 foreach (array_unique(array_map('intval', $ids)) as $id) {
1142 $option = self::get_ctc_specific_option('post-' . $id);
1143 if ($option) {
1144 $options[] = $option;
1145 }
1146 }
1147 if (! empty($options)) {
1148 $groups[] = array('label' => $post_type->labels->name, 'options' => $options);
1149 }
1150 }
1151
1152 foreach (get_taxonomies(array('public' => true), 'objects') as $taxonomy) {
1153 if (! self::is_ctc_rule_taxonomy($taxonomy)) {
1154 continue;
1155 }
1156 $args = array(
1157 'taxonomy' => $taxonomy->name,
1158 'hide_empty' => false,
1159 'number' => 10,
1160 'orderby' => 'name',
1161 );
1162 if ('' !== $query) {
1163 $args['search'] = $query;
1164 }
1165 $terms = get_terms($args);
1166 if (is_wp_error($terms) || empty($terms)) {
1167 continue;
1168 }
1169
1170 $options = array();
1171 foreach ($terms as $term) {
1172 foreach (array('tax-' . $term->term_id, 'tax-' . $term->term_id . '-posts') as $key) {
1173 $option = self::get_ctc_specific_option($key);
1174 if ($option) {
1175 $options[] = $option;
1176 }
1177 }
1178 }
1179 $groups[] = array('label' => $taxonomy->labels->name, 'options' => $options);
1180 }
1181
1182 return new WP_REST_Response($groups, 200);
1183 }
1184
1185 public static function get_ctc_device_options() {
1186 return array(
1187 'desktop' => 'Desktop',
1188 'tablet' => 'Tablet',
1189 'mobile' => 'Mobile',
1190 );
1191 }
1192
1193 // ========================================
1194 // 15. ACTIVITY LOG
1195 // ========================================
1196
1197 public static function insert_activity_log( $type = 'debug', $message = '' ) {
1198 if ( 'yes' !== get_option( 'notifier_enable_activity_log' ) ) {
1199 return;
1200 }
1201
1202 global $wpdb;
1203 $table_name = $wpdb->prefix . NOTIFIER_ACTIVITY_TABLE_NAME;
1204
1205 $wpdb->insert( $table_name, array(
1206 'timestamp' => current_time( 'mysql', true ),
1207 'message' => $message,
1208 'type' => $type,
1209 ), array( '%s', '%s', '%s' ) );
1210 }
1211 }
1212