self::erase_user( (int) $user_id ) ); } /** * The table. */ private static function table(): string { return AgentAdapt_Schema::table( 'feedback' ); } /** * A task as reports are grouped by it: lower case, runs of whitespace * made one space, trimmed. * * @param string $task Task, as the agent wrote it. */ public static function normalise( string $task ): string { return mb_strtolower( trim( (string) preg_replace( '/\s+/u', ' ', $task ) ), 'UTF-8' ); } /** * Store one report. * * @param AgentAdapt_Context $context The Session that sent it. * @param string $kind Kind, as sent (any non-empty string). * @param string $task Task, at most 200 characters. * @param string $description Description, at most 2,000 characters. * @return int|null The report's id; 0 when the store keeps no reports (retention 0); null if it could not be stored. */ public static function insert( AgentAdapt_Context $context, string $kind, string $task, string $description ): ?int { if ( AgentAdapt_Events::retention_days() <= 0 ) { return 0; } global $wpdb; $ok = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery self::table(), array( 'created' => time(), 'client_hash' => $context->client_hash, 'subject_id' => $context->subject_id, 'session_id' => $context->session_id, 'user_id' => $context->signed_in() ? $context->user_id : 0, 'kind' => $kind, 'task' => $task, 'task_key' => hash( 'sha256', self::normalise( $task ) ), 'description' => $description, ), array( '%d', '%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s' ) ); return false === $ok || ! $wpdb->insert_id ? null : (int) $wpdb->insert_id; } /** * Reports since a moment, grouped by normalised task, most reported first. * * @param int $since Unix time (inclusive). * @param int $limit Most groups returned. * @return array */ public static function groups( int $since, int $limit ): array { global $wpdb; $rows = (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT task_key, COUNT(*) AS reports, MAX(created) AS last, MAX(id) AS latest FROM %i WHERE created >= %d GROUP BY task_key ORDER BY reports DESC, latest DESC LIMIT %d', self::table(), $since, $limit ), ARRAY_A ); return array_map( static fn( $r ) => array( 'task_key' => (string) $r['task_key'], 'reports' => (int) $r['reports'], 'last' => (int) $r['last'], ), $rows ); } /** * The latest reports in one group, newest first, with each agent's name * and client ID. * * @param string $task_key Group key (groups()). * @param int $since Unix time (inclusive). * @param int $limit Most reports returned. * @return array> */ public static function in_group( string $task_key, int $since, int $limit ): array { global $wpdb; return (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT f.id, f.created, f.kind, f.task, f.description, f.client_hash, a.name AS agent_name, a.client_id FROM %i f LEFT JOIN %i a ON a.client_hash = f.client_hash WHERE f.task_key = %s AND f.created >= %d ORDER BY f.id DESC LIMIT %d', self::table(), AgentAdapt_Schema::table( 'agents' ), $task_key, $since, $limit ), ARRAY_A ); } /** * The agents that sent reports in one group: name and client ID. * * @param string $task_key Group key. * @param int $since Unix time (inclusive). * @return array */ public static function agents_in_group( string $task_key, int $since ): array { global $wpdb; return (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT DISTINCT a.name AS agent_name, a.client_id FROM %i f LEFT JOIN %i a ON a.client_hash = f.client_hash WHERE f.task_key = %s AND f.created >= %d LIMIT 20', self::table(), AgentAdapt_Schema::table( 'agents' ), $task_key, $since ), ARRAY_A ); } /** * Delete every report sent while signed in to an account. * * @param int $user_id Account. * @param int $limit Most rows deleted (0 = all). * @return int Rows deleted. */ public static function erase_user( int $user_id, int $limit = 0 ): int { if ( $user_id < 1 ) { return 0; } global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery $n = $limit > 0 ? $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE user_id = %d ORDER BY id LIMIT %d', self::table(), $user_id, $limit ) ) : $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE user_id = %d', self::table(), $user_id ) ); // phpcs:enable return (int) $n; } /** * Delete reports past the event retention window (the hourly * maintenance job). */ public static function purge(): void { global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE created < %d', self::table(), time() - max( 0, AgentAdapt_Events::retention_days() ) * DAY_IN_SECONDS ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * Register the personal data exporter. * * @param array> $exporters Exporters. * @return array> */ public static function register_exporter( $exporters ): array { $exporters = (array) $exporters; $exporters[ self::PRIVACY_KEY ] = array( 'exporter_friendly_name' => __( 'Reports from personal agents', 'agent-adapt' ), 'callback' => array( __CLASS__, 'export' ), ); return $exporters; } /** * Register the personal data eraser. * * @param array> $erasers Erasers. * @return array> */ public static function register_eraser( $erasers ): array { $erasers = (array) $erasers; $erasers[ self::PRIVACY_KEY ] = array( 'eraser_friendly_name' => __( 'Reports from personal agents', 'agent-adapt' ), 'callback' => array( __CLASS__, 'erase' ), ); return $erasers; } /** * Personal data exporter: the reports agents sent while signed in to the * account with this email address. * * @param string $email Email address. * @param int $page Page, from 1. * @return array{data: array>, done: bool} */ public static function export( $email, $page = 1 ): array { $user = get_user_by( 'email', (string) $email ); if ( ! $user instanceof WP_User ) { return array( 'data' => array(), 'done' => true ); } global $wpdb; $page = max( 1, (int) $page ); $rows = (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT f.id, f.created, f.kind, f.task, f.description, a.name AS agent_name, a.client_id FROM %i f LEFT JOIN %i a ON a.client_hash = f.client_hash WHERE f.user_id = %d ORDER BY f.id LIMIT %d OFFSET %d', self::table(), AgentAdapt_Schema::table( 'agents' ), (int) $user->ID, self::PAGE_SIZE, ( $page - 1 ) * self::PAGE_SIZE ), ARRAY_A ); $format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' ); $data = array(); foreach ( $rows as $r ) { $agent = trim( (string) $r['agent_name'] ); $data[] = array( 'group_id' => self::PRIVACY_KEY, 'group_label' => __( 'Reports from personal agents', 'agent-adapt' ), 'group_description' => __( 'Reports a personal agent sent to this store, while signed in to your account, about something it could not do for you.', 'agent-adapt' ), 'item_id' => 'agent-adapt-feedback-' . (int) $r['id'], 'data' => array( array( 'name' => __( 'Agent', 'agent-adapt' ), 'value' => trim( ( '' !== $agent ? $agent . ' ' : '' ) . (string) $r['client_id'] ) ), array( 'name' => __( 'Reported', 'agent-adapt' ), 'value' => (string) wp_date( $format, (int) $r['created'] ) ), array( 'name' => __( 'Kind', 'agent-adapt' ), 'value' => (string) $r['kind'] ), array( 'name' => __( 'What you wanted to do', 'agent-adapt' ), 'value' => (string) $r['task'] ), array( 'name' => __( 'What the agent said', 'agent-adapt' ), 'value' => (string) $r['description'] ), ), ); } return array( 'data' => $data, 'done' => count( $rows ) < self::PAGE_SIZE ); } /** * Personal data eraser: delete the reports agents sent while signed in to * the account with this email address. * * @param string $email Email address. * @param int $page Page, from 1 (each call deletes the next batch). * @return array{items_removed: bool, items_retained: bool, messages: string[], done: bool} */ public static function erase( $email, $page = 1 ): array { unset( $page ); $user = get_user_by( 'email', (string) $email ); $removed = $user instanceof WP_User ? self::erase_user( (int) $user->ID, self::PAGE_SIZE ) : 0; return array( 'items_removed' => $removed > 0, 'items_retained' => false, 'messages' => array(), 'done' => $removed < self::PAGE_SIZE, ); } }