prefix . 'emails404_sync_log'; } public static function create_table() { global $wpdb; $table_name = self::table_name(); $charset_collate = $wpdb->get_charset_collate(); // dbDelta() is the WordPress-sanctioned way to create/upgrade a // custom table - never a raw CREATE TABLE string executed directly. require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $sql = "CREATE TABLE {$table_name} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, created_at DATETIME NOT NULL, provider VARCHAR(50) NOT NULL, reference VARCHAR(191) NOT NULL, outcome VARCHAR(20) NOT NULL, http_status SMALLINT NULL, error_code VARCHAR(50) NULL, PRIMARY KEY (id), KEY created_at (created_at) ) {$charset_collate};"; dbDelta( $sql ); } public static function drop_table() { global $wpdb; $table_name = self::table_name(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name cannot be a bound parameter; built from a trusted constant prefix only, never user input. $wpdb->query( "DROP TABLE IF EXISTS {$table_name}" ); } /** * @param string $provider Provider slug, e.g. "event_tickets". * @param string $reference Safe internal reference (post IDs only - never a name or email). * @param string $outcome One of the OUTCOME_* constants. * @param int|null $http_status HTTP status from the 404 Emails response, if any. * @param string|null $error_code A safe, stable error code (e.g. "UNAUTHORIZED", "timeout") - never a raw exception message. */ public static function record( $provider, $reference, $outcome, $http_status = null, $error_code = null ) { global $wpdb; $wpdb->insert( self::table_name(), array( 'created_at' => current_time( 'mysql', true ), 'provider' => sanitize_key( $provider ), 'reference' => sanitize_text_field( $reference ), 'outcome' => sanitize_key( $outcome ), 'http_status' => null === $http_status ? null : (int) $http_status, 'error_code' => null === $error_code ? null : sanitize_key( $error_code ), ), array( '%s', '%s', '%s', '%s', '%d', '%s' ) ); self::prune(); } private static function prune() { global $wpdb; $table_name = self::table_name(); $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( self::RETENTION_DAYS * DAY_IN_SECONDS ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from a trusted constant; the only dynamic value ($cutoff) is passed via $wpdb->prepare(). $wpdb->query( $wpdb->prepare( "DELETE FROM {$table_name} WHERE created_at < %s", $cutoff ) ); $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table_name}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from a trusted constant, no dynamic input. if ( $count > self::MAX_ROWS ) { $excess = $count - self::MAX_ROWS; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from a trusted constant; $excess is cast to int above, not user input. $wpdb->query( "DELETE FROM {$table_name} ORDER BY created_at ASC LIMIT {$excess}" ); } } /** Most recent successful sync row, or null. */ public static function get_last_success() { global $wpdb; $table_name = self::table_name(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from a trusted constant; $outcome value passed via prepare(). return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table_name} WHERE outcome = %s ORDER BY created_at DESC LIMIT 1", self::OUTCOME_SUCCESS ) ); } /** Most recent failed sync row, or null. */ public static function get_last_failure() { global $wpdb; $table_name = self::table_name(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from a trusted constant; $outcome value passed via prepare(). return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table_name} WHERE outcome = %s ORDER BY created_at DESC LIMIT 1", self::OUTCOME_FAILED ) ); } }