Export/Erase Personal Data). add_filter( 'wp_privacy_personal_data_exporters', array( __CLASS__, 'register_data_exporter' ) ); add_filter( 'wp_privacy_personal_data_erasers', array( __CLASS__, 'register_data_eraser' ) ); } /** * Handle purge old data form submission. * * Deletes conversations older than specified number of days. * * @since 1.0.0 * @return void */ public static function handle_purge_old_data() { // Verify nonce. if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'agentorie_chat_purge_old_data' ) ) { wp_die( esc_html__( 'Security check failed.', 'agentorie-chat' ), 403 ); } // Check capabilities. if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'agentorie-chat' ), 403 ); } // Get retention days. $retention_days = isset( $_POST['retention_days'] ) ? absint( wp_unslash( $_POST['retention_days'] ) ) : 90; $allowed_days = array( 30, 60, 90, 180, 365 ); if ( ! in_array( $retention_days, $allowed_days, true ) ) { $retention_days = 90; } // Perform purge. $deleted_count = self::purge_old_data( $retention_days ); // Redirect back with result. $redirect_url = add_query_arg( array( 'page' => 'agentorie-chat-conversations', 'tab' => 'conversations', 'deleted' => $deleted_count, ), admin_url( 'admin.php' ) ); wp_safe_redirect( $redirect_url ); exit; } /** * Handle export visitor data form submission. * * Exports all data for a specific visitor as JSON download. * * @since 1.0.0 * @return void */ public static function handle_export_visitor_data() { // Verify nonce. if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'agentorie_chat_export_visitor_data' ) ) { wp_die( esc_html__( 'Security check failed.', 'agentorie-chat' ), 403 ); } // Check capabilities. if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'agentorie-chat' ), 403 ); } // Get visitor identifier. $identifier = isset( $_POST['visitor_identifier'] ) ? sanitize_text_field( wp_unslash( $_POST['visitor_identifier'] ) ) : ''; if ( empty( $identifier ) ) { self::redirect_with_error( 'invalid_identifier' ); } // Get visitor data. $data = self::get_visitor_data( $identifier ); if ( empty( $data['sessions'] ) && empty( $data['contacts'] ) ) { self::redirect_with_error( 'no_data_found' ); } // Generate JSON export. $export = array( 'export_date' => current_time( 'c' ), 'export_site' => get_bloginfo( 'name' ), 'visitor_identifier' => $identifier, 'data' => $data, ); $filename = 'agentorie-visitor-data-' . sanitize_file_name( $identifier ) . '-' . gmdate( 'Y-m-d' ) . '.json'; // Send JSON file. header( 'Content-Type: application/json; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); header( 'Pragma: no-cache' ); header( 'Expires: 0' ); echo wp_json_encode( $export, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE ); exit; } /** * Handle delete visitor data form submission. * * Permanently deletes all data for a specific visitor. * * @since 1.0.0 * @return void */ public static function handle_delete_visitor_data() { // Verify nonce. if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'agentorie_chat_delete_visitor_data' ) ) { wp_die( esc_html__( 'Security check failed.', 'agentorie-chat' ), 403 ); } // Check capabilities. if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'agentorie-chat' ), 403 ); } // Get visitor identifier. $identifier = isset( $_POST['visitor_identifier'] ) ? sanitize_text_field( wp_unslash( $_POST['visitor_identifier'] ) ) : ''; if ( empty( $identifier ) ) { self::redirect_with_error( 'invalid_identifier' ); } // Perform deletion. $deleted_count = self::delete_visitor_data( $identifier ); if ( 0 === $deleted_count ) { self::redirect_with_error( 'no_data_found' ); } // Redirect back with result. $redirect_url = add_query_arg( array( 'page' => 'agentorie-chat-conversations', 'tab' => 'conversations', 'anonymized' => $deleted_count, ), admin_url( 'admin.php' ) ); wp_safe_redirect( $redirect_url ); exit; } /** * Purge conversations older than specified days. * * @since 1.0.0 * @param int $days Number of days to retain. * @return int Number of deleted conversations. */ public static function purge_old_data( $days ) { global $wpdb; $sessions_table = Agentorie_Database::sessions_table(); $messages_table = Agentorie_Database::messages_table(); $contacts_table = Agentorie_Database::contacts_table(); $cutoff_date = wp_date( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) ); // Get session IDs to delete. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $session_ids = $wpdb->get_col( $wpdb->prepare( "SELECT session_id FROM {$sessions_table} WHERE created_at < %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $cutoff_date ) ); if ( empty( $session_ids ) ) { return 0; } $count = count( $session_ids ); // Delete messages for these sessions. $placeholders = implode( ',', array_fill( 0, $count, '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query( $wpdb->prepare( "DELETE FROM {$messages_table} WHERE session_id IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic placeholders $session_ids ) ); // Delete contacts for these sessions. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query( $wpdb->prepare( "DELETE FROM {$contacts_table} WHERE session_id IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic placeholders $session_ids ) ); // Delete sessions. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query( $wpdb->prepare( "DELETE FROM {$sessions_table} WHERE created_at < %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $cutoff_date ) ); return $count; } /** * Get all data for a specific visitor. * * @since 1.0.0 * @param string $identifier Visitor ID or email. * @return array Visitor data including sessions, messages, and contacts. */ public static function get_visitor_data( $identifier ) { global $wpdb; $sessions_table = Agentorie_Database::sessions_table(); $messages_table = Agentorie_Database::messages_table(); $contacts_table = Agentorie_Database::contacts_table(); $data = array( 'sessions' => array(), 'contacts' => array(), ); // Find sessions by visitor_id or by email in contacts. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from internal methods $sessions = $wpdb->get_results( $wpdb->prepare( "SELECT s.* FROM {$sessions_table} s LEFT JOIN {$contacts_table} c ON s.session_id = c.session_id WHERE s.visitor_id = %s OR s.session_id = %s OR c.email = %s GROUP BY s.session_id ORDER BY s.created_at DESC", $identifier, $identifier, $identifier ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( empty( $sessions ) ) { return $data; } foreach ( $sessions as $session ) { $session_id = $session['session_id']; // Get messages for this session. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $messages = $wpdb->get_results( $wpdb->prepare( "SELECT role, message, created_at FROM {$messages_table} WHERE session_id = %s ORDER BY created_at ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $session_id ), ARRAY_A ); $data['sessions'][] = array( 'session_id' => $session_id, 'visitor_id' => $session['visitor_id'], 'started_at' => $session['created_at'], 'messages' => $messages, ); } // Get contacts. $session_ids = wp_list_pluck( $sessions, 'session_id' ); $placeholders = implode( ',', array_fill( 0, count( $session_ids ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $contacts = $wpdb->get_results( $wpdb->prepare( "SELECT name, email, phone, message, created_at FROM {$contacts_table} WHERE session_id IN ({$placeholders}) OR email = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter array_merge( $session_ids, array( $identifier ) ) ), ARRAY_A ); $data['contacts'] = $contacts ? $contacts : array(); return $data; } /** * Delete all data for a specific visitor. * * @since 1.0.0 * @param string $identifier Visitor ID or email. * @return int Number of deleted sessions. */ public static function delete_visitor_data( $identifier ) { global $wpdb; $sessions_table = Agentorie_Database::sessions_table(); $messages_table = Agentorie_Database::messages_table(); $contacts_table = Agentorie_Database::contacts_table(); // Find session IDs. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from internal methods $session_ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT s.session_id FROM {$sessions_table} s LEFT JOIN {$contacts_table} c ON s.session_id = c.session_id WHERE s.visitor_id = %s OR s.session_id = %s OR c.email = %s", $identifier, $identifier, $identifier ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( empty( $session_ids ) ) { // Also check contacts directly by email. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $deleted = $wpdb->delete( $contacts_table, array( 'email' => $identifier ), array( '%s' ) ); return $deleted ? 1 : 0; } $count = count( $session_ids ); $placeholders = implode( ',', array_fill( 0, $count, '%s' ) ); // Delete messages. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query( $wpdb->prepare( "DELETE FROM {$messages_table} WHERE session_id IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic placeholders $session_ids ) ); // Delete contacts. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query( $wpdb->prepare( "DELETE FROM {$contacts_table} WHERE session_id IN ({$placeholders}) OR email = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic placeholders array_merge( $session_ids, array( $identifier ) ) ) ); // Delete sessions. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query( $wpdb->prepare( "DELETE FROM {$sessions_table} WHERE session_id IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic placeholders $session_ids ) ); return $count; } /** * Register data exporter for WordPress Privacy tools. * * Integrates with Tools > Export Personal Data. * * @since 1.0.0 * @param array $exporters Registered exporters. * @return array Modified exporters array. */ public static function register_data_exporter( $exporters ) { $exporters['agentorie-chat'] = array( 'exporter_friendly_name' => __( 'Agentorie Chat Data', 'agentorie-chat' ), 'callback' => array( __CLASS__, 'wp_privacy_exporter' ), ); return $exporters; } /** * Register data eraser for WordPress Privacy tools. * * Integrates with Tools > Erase Personal Data. * * @since 1.0.0 * @param array $erasers Registered erasers. * @return array Modified erasers array. */ public static function register_data_eraser( $erasers ) { $erasers['agentorie-chat'] = array( 'eraser_friendly_name' => __( 'Agentorie Chat Data', 'agentorie-chat' ), 'callback' => array( __CLASS__, 'wp_privacy_eraser' ), ); return $erasers; } /** * WordPress Privacy exporter callback. * * Called by WordPress when exporting personal data. * * @since 1.0.0 * @param string $email_address User email address. * @param int $page Page number for pagination. * @return array Export data array. */ public static function wp_privacy_exporter( $email_address, $page = 1 ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by WordPress privacy exporter callback. $export_items = array(); $data = self::get_visitor_data( $email_address ); // Export sessions and messages. foreach ( $data['sessions'] as $session ) { $messages_text = array(); foreach ( $session['messages'] as $msg ) { $messages_text[] = sprintf( '[%s] %s: %s', $msg['created_at'], $msg['role'], $msg['message'] ); } $export_items[] = array( 'group_id' => 'agentorie-chat-conversations', 'group_label' => __( 'Chat Conversations', 'agentorie-chat' ), 'group_description' => __( 'Conversations with the Agentorie chatbot.', 'agentorie-chat' ), 'item_id' => 'session-' . $session['session_id'], 'data' => array( array( 'name' => __( 'Session ID', 'agentorie-chat' ), 'value' => $session['session_id'], ), array( 'name' => __( 'Started', 'agentorie-chat' ), 'value' => $session['started_at'], ), array( 'name' => __( 'Messages', 'agentorie-chat' ), 'value' => implode( "\n", $messages_text ), ), ), ); } // Export contacts. foreach ( $data['contacts'] as $contact ) { $export_items[] = array( 'group_id' => 'agentorie-contacts', 'group_label' => __( 'Contact Requests', 'agentorie-chat' ), 'group_description' => __( 'Contact information submitted via chatbot.', 'agentorie-chat' ), 'item_id' => 'contact-' . md5( $contact['email'] . $contact['created_at'] ), 'data' => array( array( 'name' => __( 'Name', 'agentorie-chat' ), 'value' => isset( $contact['name'] ) ? $contact['name'] : '', ), array( 'name' => __( 'Email', 'agentorie-chat' ), 'value' => isset( $contact['email'] ) ? $contact['email'] : '', ), array( 'name' => __( 'Phone', 'agentorie-chat' ), 'value' => isset( $contact['phone'] ) ? $contact['phone'] : '', ), array( 'name' => __( 'Message', 'agentorie-chat' ), 'value' => isset( $contact['message'] ) ? $contact['message'] : '', ), array( 'name' => __( 'Date', 'agentorie-chat' ), 'value' => isset( $contact['created_at'] ) ? $contact['created_at'] : '', ), ), ); } return array( 'data' => $export_items, 'done' => true, ); } /** * WordPress Privacy eraser callback. * * Called by WordPress when erasing personal data. * * @since 1.0.0 * @param string $email_address User email address. * @param int $page Page number for pagination. * @return array Eraser result array. */ public static function wp_privacy_eraser( $email_address, $page = 1 ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by WordPress privacy eraser callback. $deleted = self::delete_visitor_data( $email_address ); return array( 'items_removed' => $deleted > 0, 'items_retained' => false, 'messages' => array(), 'done' => true, ); } /** * Redirect back to conversations page with error. * * @since 1.0.0 * @param string $error Error code. * @return void */ private static function redirect_with_error( $error ) { $redirect_url = add_query_arg( array( 'page' => 'agentorie-chat-conversations', 'tab' => 'conversations', 'error' => $error, ), admin_url( 'admin.php' ) ); wp_safe_redirect( $redirect_url ); exit; } }