PluginProbe
FormsCRM – Connect Forms to CRM directly / trunk
FormsCRM – Connect Forms to CRM directly vtrunk
4.4.3 4.4.2 4.4.1 4.4.0 4.3.3 trunk 1.1.0 1.2.0 1.2.1 3.0 3.1 3.1.1 3.10.0 3.11.0 3.12.0 3.12.2 3.12.3 3.12.4 3.13.0 3.13.1 3.13.2 3.13.3 3.13.4 3.13.5 3.14.0 All 63 releases
formscrm / includes / admin / class-error-log.php

class-error-log.php in FormsCRM – Connect Forms to CRM directly trunk, at includes/admin/class-error-log.php

1,031 lines 31.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Error Log Management
4 *
5 * Handles error logging and display for FormsCRM plugin.
6 *
7 * @package WordPress
8 * @author David Perez <david@closemarketing.es>
9 * @copyright 2024 Closemarketing
10 * @version 1.0
11 */
12
13 defined( 'ABSPATH' ) || exit;
14
15 require_once dirname( __DIR__ ) . '/formscrm-library/helpers-functions.php';
16
17 if ( ! class_exists( 'FORMSCRM_Error_Log' ) ) {
18 /**
19 * Class FORMSCRM_Error_Log
20 *
21 * Handles error log table creation and CRUD operations.
22 */
23 class FORMSCRM_Error_Log {
24 /**
25 * Table name
26 *
27 * @var string
28 */
29 private $table_name;
30
31 /**
32 * Whether a retry execution is currently in progress.
33 * Prevents insert_log() from creating a new row — and a new scheduler job —
34 * when create_entry() internally triggers formscrm_alert_error().
35 *
36 * @var bool
37 */
38 private $is_retrying = false;
39
40 /**
41 * Constructor
42 */
43 public function __construct() {
44 global $wpdb;
45 $this->table_name = $wpdb->prefix . 'formscrm_error_log';
46
47 add_action( 'plugins_loaded', array( $this, 'check_database_version' ) );
48 add_action( 'wp_ajax_formscrm_resend_entry', array( $this, 'ajax_resend_entry' ) );
49 add_action( 'wp_ajax_formscrm_delete_log', array( $this, 'ajax_delete_log' ) );
50 add_action( 'wp_ajax_formscrm_clear_all_logs', array( $this, 'ajax_clear_all_logs' ) );
51 add_action( 'wp_ajax_formscrm_export_csv', array( $this, 'ajax_export_csv' ) );
52 add_action( 'wp_ajax_formscrm_bulk_delete_logs', array( $this, 'ajax_bulk_delete_logs' ) );
53 add_action( 'wp_ajax_formscrm_bulk_resend_logs', array( $this, 'ajax_bulk_resend_logs' ) );
54 add_action( 'wp_ajax_formscrm_cancel_all_retries', array( $this, 'ajax_cancel_all_scheduled_retries' ) );
55
56 // Hook for automatic retry via Action Scheduler.
57 add_action( 'formscrm_retry_failed_entry', array( $this, 'retry_failed_entry' ), 10, 1 );
58
59 // Prevent Action Scheduler from retrying on its own; FormsCRM manages retries explicitly.
60 add_filter( 'action_scheduler_retry_failed_action', array( $this, 'disable_as_retry_for_formscrm' ), 10, 2 );
61 }
62
63 /**
64 * Schedule retry using Action Scheduler or WP-Cron fallback
65 *
66 * @param int $log_id Log ID to retry.
67 * @return void
68 */
69 private function schedule_action_scheduler_retry( $log_id ) {
70 $retry_delay = HOUR_IN_SECONDS;
71 $timestamp = time() + $retry_delay;
72
73 if ( function_exists( 'as_schedule_single_action' ) ) {
74 // Skip if a pending AS action already exists for this log.
75 if ( as_has_scheduled_action( 'formscrm_retry_failed_entry', array( $log_id ) ) ) {
76 return;
77 }
78 try {
79 as_schedule_single_action( $timestamp, 'formscrm_retry_failed_entry', array( $log_id ) );
80 return;
81 } catch ( Exception $e ) {
82 formscrm_debug_message( "AS schedule failed for log {$log_id}, falling back to WP-Cron: {$e->getMessage()}" );
83 }
84 }
85
86 // Fallback to WP-Cron if Action Scheduler not available.
87 if ( ! wp_next_scheduled( 'formscrm_retry_failed_entry', array( $log_id ) ) ) {
88 wp_schedule_single_event( $timestamp, 'formscrm_retry_failed_entry', array( $log_id ) );
89 }
90 }
91
92 /**
93 * Cancel all pending retry actions for a log entry (Action Scheduler + WP-Cron).
94 *
95 * @param int $log_id Log ID.
96 * @return void
97 */
98 private function cancel_scheduled_retry( $log_id ) {
99 if ( function_exists( 'as_unschedule_all_actions' ) ) {
100 as_unschedule_all_actions( 'formscrm_retry_failed_entry', array( $log_id ) );
101 }
102 wp_clear_scheduled_hook( 'formscrm_retry_failed_entry', array( $log_id ) );
103 }
104
105 /**
106 * Prevent Action Scheduler from retrying formscrm_retry_failed_entry on its own.
107 * FormsCRM manages retry scheduling explicitly via schedule_retry().
108 *
109 * @param int $attempts Number of retries AS would make.
110 * @param object $action The AS action object.
111 * @return int
112 */
113 public function disable_as_retry_for_formscrm( $attempts, $action ) {
114 if ( 'formscrm_retry_failed_entry' === $action->get_hook() ) {
115 return 0;
116 }
117 return $attempts;
118 }
119
120 /**
121 * Check database version and create/update table if needed
122 *
123 * @return void
124 */
125 public function check_database_version() {
126 $installed_version = get_option( 'formscrm_error_log_db_version', '0' );
127 $current_version = '1.2';
128
129 if ( version_compare( $installed_version, $current_version, '<' ) ) {
130 $this->create_table();
131 update_option( 'formscrm_error_log_db_version', $current_version );
132 }
133 }
134
135 /**
136 * Create error log table
137 *
138 * @return void
139 */
140 public function create_table() {
141 global $wpdb;
142
143 $charset_collate = $wpdb->get_charset_collate();
144
145 $sql = "CREATE TABLE {$this->table_name} (
146 id bigint(20) NOT NULL AUTO_INCREMENT,
147 error_date datetime NOT NULL,
148 crm_type varchar(100) NOT NULL,
149 error_message text NOT NULL,
150 form_type varchar(50) DEFAULT NULL,
151 form_type_title varchar(255) DEFAULT NULL,
152 form_id varchar(50) DEFAULT NULL,
153 form_name varchar(255) DEFAULT NULL,
154 entry_id varchar(50) DEFAULT NULL,
155 lead_data longtext NOT NULL,
156 api_url text DEFAULT NULL,
157 json_request longtext DEFAULT NULL,
158 status varchar(20) DEFAULT 'failed',
159 resend_attempts int(11) DEFAULT 0,
160 last_resend_date datetime DEFAULT NULL,
161 PRIMARY KEY (id),
162 KEY crm_type (crm_type),
163 KEY status (status),
164 KEY error_date (error_date)
165 ) $charset_collate;";
166
167 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
168 dbDelta( $sql );
169 }
170
171 /**
172 * Insert error log
173 *
174 * @param string $crm CRM type.
175 * @param string $error Error message.
176 * @param array $data Lead data.
177 * @param string $url API URL.
178 * @param string $json JSON request.
179 * @param array $form_info Form information.
180 * @return int|false Log ID or false on failure.
181 */
182 public function insert_log( $crm, $error, $data, $url = '', $json = '', $form_info = array() ) {
183 // Do not create a new log row during a retry: it would reset resend_attempts to 0
184 // and schedule an extra Action Scheduler job, bypassing the 3-attempt cap.
185 if ( $this->is_retrying ) {
186 return false;
187 }
188
189 global $wpdb;
190
191 $log_data = array(
192 'error_date' => current_time( 'mysql' ),
193 'crm_type' => sanitize_text_field( $crm ),
194 'error_message' => sanitize_textarea_field( $error ),
195 'form_type' => isset( $form_info['form_type'] ) ? sanitize_text_field( $form_info['form_type'] ) : null,
196 'form_type_title' => isset( $form_info['form_type_title'] ) ? sanitize_text_field( $form_info['form_type_title'] ) : null,
197 'form_id' => isset( $form_info['form_id'] ) ? sanitize_text_field( $form_info['form_id'] ) : null,
198 'form_name' => isset( $form_info['form_name'] ) ? sanitize_text_field( $form_info['form_name'] ) : null,
199 'entry_id' => isset( $form_info['entry_id'] ) ? sanitize_text_field( $form_info['entry_id'] ) : null,
200 'lead_data' => wp_json_encode( $data ),
201 'api_url' => $url ? esc_url_raw( $url ) : null,
202 'json_request' => $json ? wp_json_encode( json_decode( $json ) ) : null,
203 'status' => 'failed',
204 );
205
206 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
207 $result = $wpdb->insert( $this->table_name, $log_data );
208
209 if ( $result ) {
210 $log_id = $wpdb->insert_id;
211
212 // Schedule automatic retry using Action Scheduler (if available).
213 $this->schedule_action_scheduler_retry( $log_id );
214
215 return $log_id;
216 }
217
218 return false;
219 }
220
221 /**
222 * Get error logs
223 *
224 * @param array $args Query arguments.
225 * @return array Array of log entries.
226 */
227 public function get_logs( $args = array() ) {
228 global $wpdb;
229
230 $defaults = array(
231 'per_page' => 20,
232 'page' => 1,
233 'status' => '',
234 'crm_type' => '',
235 'orderby' => 'error_date',
236 'order' => 'DESC',
237 );
238
239 $args = wp_parse_args( $args, $defaults );
240
241 $where = array( '1=1' );
242
243 if ( ! empty( $args['status'] ) ) {
244 $where[] = $wpdb->prepare( 'status = %s', $args['status'] );
245 }
246
247 if ( ! empty( $args['crm_type'] ) ) {
248 $where[] = $wpdb->prepare( 'crm_type = %s', $args['crm_type'] );
249 }
250
251 $where_clause = implode( ' AND ', $where );
252 $offset = ( $args['page'] - 1 ) * $args['per_page'];
253 $order_by = sanitize_sql_orderby( $args['orderby'] . ' ' . $args['order'] );
254
255 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Variables are properly sanitized above.
256 $query = $wpdb->prepare(
257 "SELECT * FROM {$this->table_name} WHERE {$where_clause} ORDER BY {$order_by} LIMIT %d OFFSET %d",
258 $args['per_page'],
259 $offset
260 );
261 // phpcs:enable
262
263 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query is prepared above and uses custom table.
264 return $wpdb->get_results( $query );
265 }
266
267 /**
268 * Get total count of logs
269 *
270 * @param array $args Query arguments.
271 * @return int Total count.
272 */
273 public function get_total_count( $args = array() ) {
274 global $wpdb;
275
276 $defaults = array(
277 'status' => '',
278 'crm_type' => '',
279 );
280
281 $args = wp_parse_args( $args, $defaults );
282
283 $where = array( '1=1' );
284
285 if ( ! empty( $args['status'] ) ) {
286 $where[] = $wpdb->prepare( 'status = %s', $args['status'] );
287 }
288
289 if ( ! empty( $args['crm_type'] ) ) {
290 $where[] = $wpdb->prepare( 'crm_type = %s', $args['crm_type'] );
291 }
292
293 $where_clause = implode( ' AND ', $where );
294
295 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Where clause is properly prepared.
296 $query = "SELECT COUNT(*) FROM {$this->table_name} WHERE {$where_clause}";
297 // phpcs:enable
298
299 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query is prepared above and uses custom table.
300 return (int) $wpdb->get_var( $query );
301 }
302
303 /**
304 * Get log by ID
305 *
306 * @param int $log_id Log ID.
307 * @return object|null Log entry or null.
308 */
309 public function get_log( $log_id ) {
310 global $wpdb;
311
312 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
313 return $wpdb->get_row(
314 $wpdb->prepare(
315 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
316 "SELECT * FROM {$this->table_name} WHERE id = %d",
317 $log_id
318 )
319 );
320 }
321
322 /**
323 * Update log status
324 *
325 * @param int $log_id Log ID.
326 * @param string $status New status.
327 * @return bool Success status.
328 */
329 public function update_status( $log_id, $status ) {
330 global $wpdb;
331
332 $update_data = array( 'status' => $status );
333
334 if ( 'resent' === $status || 'success' === $status ) {
335 $update_data['last_resend_date'] = current_time( 'mysql' );
336 }
337
338 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
339 return $wpdb->update(
340 $this->table_name,
341 $update_data,
342 array( 'id' => $log_id )
343 );
344 }
345
346 /**
347 * Increment resend attempts
348 *
349 * @param int $log_id Log ID.
350 * @return bool Success status.
351 */
352 public function increment_resend_attempts( $log_id ) {
353 global $wpdb;
354
355 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
356 return $wpdb->query(
357 $wpdb->prepare(
358 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
359 "UPDATE {$this->table_name} SET resend_attempts = resend_attempts + 1, last_resend_date = %s WHERE id = %d",
360 current_time( 'mysql' ),
361 $log_id
362 )
363 );
364 }
365
366 /**
367 * Delete log entry
368 *
369 * @param int $log_id Log ID.
370 * @return bool Success status.
371 */
372 public function delete_log( $log_id ) {
373 global $wpdb;
374
375 // Cancel any scheduled retry before deleting.
376 $this->cancel_scheduled_retry( $log_id );
377
378 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
379 return $wpdb->delete(
380 $this->table_name,
381 array( 'id' => $log_id ),
382 array( '%d' )
383 );
384 }
385
386 /**
387 * Delete all logs
388 *
389 * @return bool Success status.
390 */
391 public function clear_all_logs() {
392 global $wpdb;
393
394 // Get all log IDs to clear scheduled events.
395 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
396 $log_ids = $wpdb->get_col( "SELECT id FROM {$this->table_name}" );
397
398 // Cancel scheduled retries for all logs.
399 foreach ( $log_ids as $log_id ) {
400 $this->cancel_scheduled_retry( $log_id );
401 }
402
403 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
404 return $wpdb->query( "TRUNCATE TABLE {$this->table_name}" );
405 }
406
407 /**
408 * AJAX handler for resending entry
409 *
410 * @return void
411 */
412 public function ajax_resend_entry() {
413 check_ajax_referer( 'formscrm_error_log_nonce', 'nonce' );
414
415 if ( ! current_user_can( 'manage_options' ) ) {
416 wp_send_json_error( array( 'message' => __( 'Permission denied', 'formscrm' ) ) );
417 }
418
419 $log_id = isset( $_POST['log_id'] ) ? intval( $_POST['log_id'] ) : 0;
420
421 if ( ! $log_id ) {
422 wp_send_json_error( array( 'message' => __( 'Invalid log ID', 'formscrm' ) ) );
423 }
424
425 $log = $this->get_log( $log_id );
426
427 if ( ! $log ) {
428 wp_send_json_error( array( 'message' => __( 'Log entry not found', 'formscrm' ) ) );
429 }
430
431 // Decode lead data.
432 $lead_data = json_decode( $log->lead_data, true );
433
434 if ( ! $lead_data ) {
435 wp_send_json_error( array( 'message' => __( 'Invalid lead data', 'formscrm' ) ) );
436 }
437
438 // Get CRM settings.
439 $settings = formscrm_get_crm_settings( $log->form_type );
440
441 if ( empty( $settings ) ) {
442 wp_send_json_error(
443 array(
444 'message' => __( 'CRM settings not found. Please configure the CRM connection in FormsCRM settings.', 'formscrm' ),
445 )
446 );
447 }
448
449 // Merge the feed's own meta (e.g. merge strategy) so resends behave
450 // exactly like the original submission instead of always creating.
451 $settings = formscrm_merge_feed_meta_into_settings( $settings, (string) $log->form_type, (string) $log->form_id, (string) $log->entry_id );
452
453 // Get CRM API class.
454 $api_class = formscrm_get_api_class( $log->crm_type );
455
456 if ( ! $api_class ) {
457 $error_msg = sprintf(
458 /* translators: %s: CRM type name */
459 __( 'CRM API class not found for "%s". Please check if the CRM plugin is active and the library file exists.', 'formscrm' ),
460 $log->crm_type
461 );
462
463 wp_send_json_error(
464 array(
465 'message' => $error_msg,
466 'crm_type' => $log->crm_type,
467 'log_id' => $log_id,
468 )
469 );
470 }
471
472 // Verify API class has create_entry method.
473 if ( ! method_exists( $api_class, 'create_entry' ) ) {
474 $error_msg = sprintf(
475 /* translators: %s: CRM type name */
476 __( 'CRM API class for "%s" does not have create_entry method.', 'formscrm' ),
477 $log->crm_type
478 );
479
480 wp_send_json_error( array( 'message' => $error_msg ) );
481 }
482
483 try {
484 $response = $api_class->create_entry( $settings, $lead_data, $log_id );
485
486 if ( isset( $response['status'] ) && 'ok' === strtolower( $response['status'] ) ) {
487 $this->update_status( $log_id, 'success' );
488
489 // Cancel any scheduled retries.
490 $this->cancel_scheduled_retry( $log_id );
491
492 formscrm_add_entry_note(
493 $log->form_type,
494 $log->entry_id,
495 sprintf(
496 /* translators: %s: CRM name */
497 __( 'FormsCRM manual resend success (%s)', 'formscrm' ),
498 esc_html( $log->crm_type )
499 ),
500 'success'
501 );
502
503 wp_send_json_success(
504 array(
505 'message' => __( 'Entry resent successfully', 'formscrm' ),
506 )
507 );
508 } else {
509 $error_message = isset( $response['message'] ) ? $response['message'] : __( 'Unknown error occurred', 'formscrm' );
510
511 formscrm_add_entry_note(
512 $log->form_type,
513 $log->entry_id,
514 sprintf(
515 /* translators: %1$s: CRM name, %2$s: error message */
516 __( 'FormsCRM manual resend failed (%1$s): %2$s', 'formscrm' ),
517 esc_html( $log->crm_type ),
518 esc_html( $error_message )
519 ),
520 'error'
521 );
522
523 wp_send_json_error(
524 array(
525 'message' => $error_message,
526 )
527 );
528 }
529 } catch ( Exception $e ) {
530 formscrm_add_entry_note(
531 $log->form_type,
532 $log->entry_id,
533 sprintf(
534 /* translators: %1$s: CRM name, %2$s: exception message */
535 __( 'FormsCRM manual resend error (%1$s): %2$s', 'formscrm' ),
536 esc_html( $log->crm_type ),
537 esc_html( $e->getMessage() )
538 ),
539 'error'
540 );
541
542 wp_send_json_error(
543 array(
544 'message' => $e->getMessage(),
545 )
546 );
547 }
548 }
549
550 /**
551 * AJAX handler for deleting log
552 *
553 * @return void
554 */
555 public function ajax_delete_log() {
556 check_ajax_referer( 'formscrm_error_log_nonce', 'nonce' );
557
558 if ( ! current_user_can( 'manage_options' ) ) {
559 wp_send_json_error( array( 'message' => __( 'Permission denied', 'formscrm' ) ) );
560 }
561
562 $log_id = isset( $_POST['log_id'] ) ? intval( $_POST['log_id'] ) : 0;
563
564 if ( ! $log_id ) {
565 wp_send_json_error( array( 'message' => __( 'Invalid log ID', 'formscrm' ) ) );
566 }
567
568 if ( $this->delete_log( $log_id ) ) {
569 wp_send_json_success( array( 'message' => __( 'Log deleted successfully', 'formscrm' ) ) );
570 } else {
571 wp_send_json_error( array( 'message' => __( 'Failed to delete log', 'formscrm' ) ) );
572 }
573 }
574
575 /**
576 * AJAX handler for clearing all logs
577 *
578 * @return void
579 */
580 public function ajax_clear_all_logs() {
581 check_ajax_referer( 'formscrm_error_log_nonce', 'nonce' );
582
583 if ( ! current_user_can( 'manage_options' ) ) {
584 wp_send_json_error( array( 'message' => __( 'Permission denied', 'formscrm' ) ) );
585 }
586
587 if ( $this->clear_all_logs() ) {
588 wp_send_json_success( array( 'message' => __( 'All logs cleared successfully', 'formscrm' ) ) );
589 } else {
590 wp_send_json_error( array( 'message' => __( 'Failed to clear logs', 'formscrm' ) ) );
591 }
592 }
593
594 /**
595 * AJAX handler for exporting logs to CSV
596 *
597 * @return void
598 */
599 public function ajax_export_csv() {
600 check_ajax_referer( 'formscrm_error_log_nonce', 'nonce' );
601
602 if ( ! current_user_can( 'manage_options' ) ) {
603 wp_send_json_error( array( 'message' => __( 'Permission denied', 'formscrm' ) ) );
604 }
605
606 $date_from = isset( $_POST['date_from'] ) ? sanitize_text_field( wp_unslash( $_POST['date_from'] ) ) : '';
607 $date_to = isset( $_POST['date_to'] ) ? sanitize_text_field( wp_unslash( $_POST['date_to'] ) ) : '';
608
609 // Validate date format (YYYY-MM-DD) only when provided.
610 if ( ! empty( $date_from ) && ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date_from ) ) {
611 wp_send_json_error( array( 'message' => __( 'Invalid date format', 'formscrm' ) ) );
612 }
613 if ( ! empty( $date_to ) && ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date_to ) ) {
614 wp_send_json_error( array( 'message' => __( 'Invalid date format', 'formscrm' ) ) );
615 }
616
617 $csv_data = $this->export_csv( $date_from, $date_to );
618
619 if ( ! $csv_data ) {
620 wp_send_json_error( array( 'message' => __( 'No logs found', 'formscrm' ) ) );
621 }
622
623 // Generate CSV content in memory.
624 $csv_content = $this->generate_csv_content( $csv_data );
625
626 // Build filename based on whether dates were provided.
627 if ( $date_from && $date_to ) {
628 $filename = 'formscrm-error-logs-' . $date_from . '-to-' . $date_to . '.csv';
629 } else {
630 $filename = 'formscrm-error-logs-all.csv';
631 }
632
633 // Return CSV content to client for download.
634 wp_send_json_success(
635 array(
636 'csv_content' => $csv_content,
637 'filename' => $filename,
638 )
639 );
640 }
641
642 /**
643 * Generate CSV content from array data
644 *
645 * @param array $csv_data Array of rows to export.
646 * @return string CSV formatted string.
647 */
648 private function generate_csv_content( $csv_data ) {
649 $output = '';
650
651 foreach ( $csv_data as $row ) {
652 $output .= $this->escape_csv_row( $row ) . "\n";
653 }
654
655 return $output;
656 }
657
658 /**
659 * Escape and format a single CSV row
660 *
661 * @param array $row Row data.
662 * @return string Formatted CSV row.
663 */
664 private function escape_csv_row( $row ) {
665 $escaped = array();
666
667 foreach ( $row as $field ) {
668 if ( null === $field ) {
669 $escaped[] = '';
670 } elseif ( strpos( $field, '"' ) !== false || strpos( $field, ',' ) !== false || strpos( $field, "\n" ) !== false ) {
671 $escaped[] = '"' . str_replace( '"', '""', $field ) . '"';
672 } else {
673 $escaped[] = $field;
674 }
675 }
676
677 return implode( ',', $escaped );
678 }
679
680 /**
681 * Schedule automatic retry for failed entry
682 *
683 * @param int $log_id Log ID.
684 * @return void
685 */
686 private function schedule_retry( $log_id ) {
687 $log = $this->get_log( $log_id );
688
689 if ( ! $log ) {
690 return;
691 }
692
693 // Only schedule if we haven't reached max attempts.
694 if ( $log->resend_attempts >= 3 ) {
695 return;
696 }
697
698 // Use Action Scheduler (same as initial schedule).
699 $this->schedule_action_scheduler_retry( $log_id );
700 }
701
702 /**
703 * Get next scheduled retry timestamp for a log entry
704 *
705 * @param int $log_id Log ID.
706 * @return int|false Timestamp or false if not scheduled.
707 */
708 public function get_next_retry_time( $log_id ) {
709 $timestamp = wp_next_scheduled( 'formscrm_retry_failed_entry', array( $log_id ) );
710 return $timestamp;
711 }
712
713 /**
714 * Export logs to CSV within date range
715 *
716 * @param string $date_from Start date (Y-m-d format).
717 * @param string $date_to End date (Y-m-d format).
718 * @return array|false CSV data or false on failure.
719 */
720 public function export_csv( $date_from, $date_to ) {
721 global $wpdb;
722
723 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
724 if ( ! empty( $date_from ) && ! empty( $date_to ) ) {
725 // Convert dates to MySQL datetime format (start and end of day).
726 $from_datetime = $date_from . ' 00:00:00';
727 $to_datetime = $date_to . ' 23:59:59';
728
729 $query = $wpdb->prepare(
730 "SELECT id, error_date, crm_type, form_type, form_type_title, form_name, entry_id, error_message, status, resend_attempts, last_resend_date
731 FROM {$this->table_name}
732 WHERE error_date >= %s AND error_date <= %s
733 ORDER BY error_date DESC",
734 $from_datetime,
735 $to_datetime
736 );
737 } else {
738 $query = "SELECT id, error_date, crm_type, form_type, form_type_title, form_name, entry_id, error_message, status, resend_attempts, last_resend_date
739 FROM {$this->table_name}
740 ORDER BY error_date DESC";
741 }
742 // phpcs:enable
743
744 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
745 $logs = $wpdb->get_results( $query );
746
747 if ( empty( $logs ) ) {
748 return false;
749 }
750
751 // Prepare CSV headers.
752 $headers = array(
753 'ID',
754 'Date',
755 'CRM Type',
756 'Form Type',
757 'Form Name',
758 'Entry ID',
759 'Error Message',
760 'Status',
761 'Resend Attempts',
762 'Last Resend Date',
763 );
764
765 $csv_data = array( $headers );
766
767 // Add rows.
768 foreach ( $logs as $log ) {
769 $csv_data[] = array(
770 $log->id,
771 $log->error_date,
772 $log->crm_type,
773 $log->form_type,
774 $log->form_name ?? '',
775 $log->entry_id ?? '',
776 $log->error_message,
777 $log->status,
778 $log->resend_attempts,
779 $log->last_resend_date ?? '',
780 );
781 }
782
783 return $csv_data;
784 }
785
786 /**
787 * Retry failed entry automatically (called by cron)
788 *
789 * @param int $log_id Log ID.
790 * @return void
791 */
792 public function retry_failed_entry( $log_id ) {
793 $log = $this->get_log( $log_id );
794
795 if ( ! $log || 'failed' !== $log->status ) {
796 formscrm_debug_message( "Retry skipped for log {$log_id}: log not found or not in failed status" );
797 return;
798 }
799
800 // Check if we've reached max attempts.
801 if ( $log->resend_attempts >= 3 ) {
802 formscrm_debug_message( "Retry skipped for log {$log_id}: max attempts reached ({$log->resend_attempts}/3)" );
803 return;
804 }
805
806 formscrm_debug_message( "Starting auto-retry for log {$log_id} (attempt {$log->resend_attempts}/3)" );
807
808 // Decode lead data.
809 $lead_data = json_decode( $log->lead_data, true );
810
811 if ( ! $lead_data ) {
812 formscrm_debug_message( "Retry failed for log {$log_id}: invalid lead data" );
813 return;
814 }
815
816 // Get CRM settings.
817 $settings = formscrm_get_crm_settings( $log->form_type );
818
819 if ( empty( $settings ) ) {
820 formscrm_debug_message( "Retry failed for log {$log_id}: no CRM settings found for form type {$log->form_type}" );
821 return;
822 }
823
824 // Merge the feed's own meta (e.g. merge strategy) so retries behave
825 // exactly like the original submission instead of always creating.
826 $settings = formscrm_merge_feed_meta_into_settings( $settings, (string) $log->form_type, (string) $log->form_id, (string) $log->entry_id );
827
828 // Get CRM API class.
829 $api_class = formscrm_get_api_class( $log->crm_type );
830
831 if ( ! $api_class || ! method_exists( $api_class, 'create_entry' ) ) {
832 formscrm_debug_message( "Retry failed for log {$log_id}: CRM API class not found or missing create_entry method" );
833 return;
834 }
835
836 // Increment attempts before trying.
837 $this->increment_resend_attempts( $log_id );
838
839 $this->is_retrying = true;
840 try {
841 $response = $api_class->create_entry( $settings, $lead_data, $log_id );
842
843 if ( isset( $response['status'] ) && 'ok' === strtolower( $response['status'] ) ) {
844 // Success - update status.
845 $this->update_status( $log_id, 'success' );
846 formscrm_debug_message( "Auto-retry SUCCESS for log {$log_id}: status updated to 'success'" );
847
848 // Cancel any pending scheduled retries (AS + WP-Cron).
849 $this->cancel_scheduled_retry( $log_id );
850
851 formscrm_add_entry_note(
852 $log->form_type,
853 $log->entry_id,
854 sprintf(
855 /* translators: %1$s: CRM name, %2$s: attempt number */
856 __( 'FormsCRM auto-retry success (%1$s) - Attempt %2$s/3', 'formscrm' ),
857 esc_html( $log->crm_type ),
858 esc_html( (string) $log->resend_attempts )
859 ),
860 'success'
861 );
862
863 // Clear any scheduled retries.
864 wp_clear_scheduled_hook( 'formscrm_retry_failed_entry', array( $log_id ) );
865 } else {
866 $error_msg = isset( $response['message'] ) ? $response['message'] : 'Unknown error';
867 formscrm_debug_message( "Auto-retry FAILED for log {$log_id}: {$error_msg}" );
868
869 formscrm_add_entry_note(
870 $log->form_type,
871 $log->entry_id,
872 sprintf(
873 /* translators: %1$s: CRM name, %2$s: attempt number, %3$s: error message */
874 __( 'FormsCRM auto-retry failed (%1$s) - Attempt %2$s/3: %3$s', 'formscrm' ),
875 esc_html( $log->crm_type ),
876 esc_html( (string) $log->resend_attempts ),
877 esc_html( $error_msg )
878 ),
879 'error'
880 );
881
882 // Failed - check if we should schedule another retry.
883 $log = $this->get_log( $log_id );
884 if ( $log && $log->resend_attempts < 3 ) {
885 $this->schedule_retry( $log_id );
886 formscrm_debug_message( "Scheduled next retry for log {$log_id}" );
887 } else {
888 wp_clear_scheduled_hook( 'formscrm_retry_failed_entry', array( $log_id ) );
889 formscrm_debug_message( "No more retries scheduled for log {$log_id}: max attempts reached" );
890 }
891 }
892 } catch ( Exception $e ) {
893 formscrm_debug_message( "Auto-retry EXCEPTION for log {$log_id}: {$e->getMessage()}" );
894
895 formscrm_add_entry_note(
896 $log->form_type,
897 $log->entry_id,
898 sprintf(
899 /* translators: %1$s: CRM name, %2$s: attempt number, %3$s: exception message */
900 __( 'FormsCRM auto-retry error (%1$s) - Attempt %2$s/3: %3$s', 'formscrm' ),
901 esc_html( $log->crm_type ),
902 esc_html( (string) $log->resend_attempts ),
903 esc_html( $e->getMessage() )
904 ),
905 'error'
906 );
907
908 // Failed - check if we should schedule another retry.
909 $log = $this->get_log( $log_id );
910 if ( $log && $log->resend_attempts < 3 ) {
911 $this->schedule_retry( $log_id );
912 formscrm_debug_message( "Scheduled next retry for log {$log_id}" );
913 } else {
914 wp_clear_scheduled_hook( 'formscrm_retry_failed_entry', array( $log_id ) );
915 formscrm_debug_message( "No more retries scheduled for log {$log_id}: max attempts reached" );
916 }
917 } finally {
918 $this->is_retrying = false;
919 }
920 }
921
922 /**
923 * AJAX handler for bulk deleting logs
924 *
925 * @return void
926 */
927 public function ajax_bulk_delete_logs() {
928 check_ajax_referer( 'formscrm_error_log_nonce', 'nonce' );
929
930 if ( ! current_user_can( 'manage_options' ) ) {
931 wp_send_json_error( array( 'message' => __( 'Permission denied', 'formscrm' ) ) );
932 }
933
934 $log_ids = isset( $_POST['log_ids'] ) ? array_map( 'intval', wp_unslash( $_POST['log_ids'] ) ) : array();
935
936 if ( empty( $log_ids ) ) {
937 wp_send_json_error( array( 'message' => __( 'No logs selected', 'formscrm' ) ) );
938 }
939
940 foreach ( $log_ids as $log_id ) {
941 $this->delete_log( $log_id );
942 }
943
944 wp_send_json_success( array( 'message' => __( 'Selected logs deleted successfully', 'formscrm' ) ) );
945 }
946
947 /**
948 * AJAX handler for bulk resending logs
949 *
950 * Enqueues all selected logs for resend via Action Scheduler.
951 * Returns immediately; processing happens in background.
952 *
953 * @return void
954 */
955 public function ajax_bulk_resend_logs() {
956 check_ajax_referer( 'formscrm_error_log_nonce', 'nonce' );
957
958 if ( ! current_user_can( 'manage_options' ) ) {
959 wp_send_json_error( array( 'message' => __( 'Permission denied', 'formscrm' ) ) );
960 }
961
962 $log_ids = isset( $_POST['log_ids'] ) ? array_map( 'intval', wp_unslash( $_POST['log_ids'] ) ) : array();
963
964 if ( empty( $log_ids ) ) {
965 wp_send_json_error( array( 'message' => __( 'No logs selected', 'formscrm' ) ) );
966 }
967
968 // Enqueue all logs for resend via Action Scheduler (stagger by 1 second each).
969 $base_time = time();
970 $index = 0;
971
972 foreach ( $log_ids as $log_id ) {
973 $scheduled_time = $base_time + $index;
974
975 if ( function_exists( 'as_schedule_single_action' ) ) {
976 // Skip if a pending AS action already exists for this log.
977 if ( ! as_has_scheduled_action( 'formscrm_retry_failed_entry', array( $log_id ) ) ) {
978 try {
979 as_schedule_single_action( $scheduled_time, 'formscrm_retry_failed_entry', array( $log_id ) );
980 } catch ( Exception $e ) {
981 if ( ! wp_next_scheduled( 'formscrm_retry_failed_entry', array( $log_id ) ) ) {
982 wp_schedule_single_event( $scheduled_time, 'formscrm_retry_failed_entry', array( $log_id ) );
983 }
984 }
985 }
986 } elseif ( ! wp_next_scheduled( 'formscrm_retry_failed_entry', array( $log_id ) ) ) {
987 wp_schedule_single_event( $scheduled_time, 'formscrm_retry_failed_entry', array( $log_id ) );
988 }
989 ++$index;
990 }
991
992 wp_send_json_success(
993 array(
994 'success' => count( $log_ids ),
995 'failed' => 0,
996 )
997 );
998 }
999
1000 /**
1001 * AJAX handler to cancel all pending scheduled retries
1002 *
1003 * Removes every pending formscrm_retry_failed_entry action from both
1004 * Action Scheduler and WP-Cron without deleting any log entries.
1005 *
1006 * @return void
1007 */
1008 public function ajax_cancel_all_scheduled_retries() {
1009 check_ajax_referer( 'formscrm_error_log_nonce', 'nonce' );
1010
1011 if ( ! current_user_can( 'manage_options' ) ) {
1012 wp_send_json_error( array( 'message' => __( 'Permission denied', 'formscrm' ) ) );
1013 }
1014
1015 // Cancel all pending AS actions for this hook in one call.
1016 if ( function_exists( 'as_unschedule_all_actions' ) ) {
1017 as_unschedule_all_actions( 'formscrm_retry_failed_entry' );
1018 }
1019
1020 // Clear WP-Cron fallback events (no args = clears all scheduled events for the hook).
1021 wp_clear_scheduled_hook( 'formscrm_retry_failed_entry' );
1022
1023 wp_send_json_success( array( 'message' => __( 'All scheduled retries have been cancelled.', 'formscrm' ) ) );
1024 }
1025 }
1026 }
1027
1028 // Initialize error log.
1029 global $formscrm_error_log;
1030 $formscrm_error_log = new FORMSCRM_Error_Log();
1031