*/ public static function register_exporter( $exporters ): array { $exporters = is_array( $exporters ) ? $exporters : array(); $exporters[ self::ID ] = array( 'exporter_friendly_name' => __( '85.pl | Android SMS API', '85pl-android-sms-api' ), 'callback' => array( self::class, 'export' ), ); return $exporters; } /** * Add the eraser. * * @param mixed $erasers Erasers so far. * @return array */ public static function register_eraser( $erasers ): array { $erasers = is_array( $erasers ) ? $erasers : array(); $erasers[ self::ID ] = array( 'eraser_friendly_name' => __( '85.pl | Android SMS API', '85pl-android-sms-api' ), 'callback' => array( self::class, 'erase' ), ); return $erasers; } /** * Phone numbers (E.164) that belong to an e-mail address. * * @param string $email E-mail address. * @return array */ public static function phones_for_email( string $email ): array { if ( ! is_email( $email ) ) { return array(); } /** * Filters the phone numbers of a person, found by e-mail address, for export and erasure. * * @param array $phones Numbers in E.164. * @param string $email E-mail address. */ $phones = apply_filters( 'smsportal_privacy_phones', array(), $email ); $valid = array(); foreach ( is_array( $phones ) ? $phones : array() as $phone ) { $normalized = is_string( $phone ) ? Phone::normalize( $phone, Phone::default_country_code() ) : null; if ( null !== $normalized ) { $valid[ $normalized ] = true; } } return array_keys( $valid ); } /** * Exporter: SMS, replies and the block list entry of the person's numbers. * * @param string $email E-mail address. * @param int $page Page, from 1. * @return array{data:array>,done:bool} */ public static function export( $email, $page = 1 ): array { $phones = self::phones_for_email( (string) $email ); if ( array() === $phones ) { return array( 'data' => array(), 'done' => true, ); } $offset = ( max( 1, (int) $page ) - 1 ) * self::PAGE_SIZE; $messages = self::messages( $phones, $offset ); $replies = self::replies( $phones, $offset ); $data = array_merge( array_map( array( self::class, 'message_item' ), $messages ), array_map( array( self::class, 'reply_item' ), $replies ) ); if ( 0 === $offset ) { $data = array_merge( $data, array_map( array( self::class, 'block_item' ), self::blocked( $phones ) ) ); } return array( 'data' => $data, 'done' => count( $messages ) < self::PAGE_SIZE && count( $replies ) < self::PAGE_SIZE, ); } /** * Eraser: delete the SMS and replies of the person's numbers and what the integrations hold (reply * notes on orders). Block list entries stay, so a person who opted out does not get SMS again. * * @param string $email E-mail address. * @param int $page Page, from 1 (each call deletes the next batch). * @return array{items_removed:bool,items_retained:bool,messages:array,done:bool} */ public static function erase( $email, $page = 1 ): array { unset( $page ); $email = (string) $email; /** * Filters the number of other items of a person the integrations erased (e.g. WooCommerce * order notes with SMS replies). Runs on every eraser call; return 0 when nothing was left. * * @param int $removed Items removed so far. * @param string $email E-mail address. */ $other = is_email( $email ) ? (int) apply_filters( 'smsportal_privacy_erase', 0, $email ) : 0; $phones = self::phones_for_email( $email ); if ( array() === $phones ) { return array( 'items_removed' => $other > 0, 'items_retained' => false, 'messages' => array(), 'done' => true, ); } $messages = self::delete_messages( $phones ); $replies = self::delete_replies( $phones ); $done = $messages < self::PAGE_SIZE && $replies < self::PAGE_SIZE; $retained = $done && array() !== self::blocked( $phones ); return array( 'items_removed' => $messages + $replies + $other > 0, 'items_retained' => $retained, 'messages' => $retained ? array( __( 'The phone number stays on the SMS block list, so this site does not send SMS to it again.', '85pl-android-sms-api' ) ) : array(), 'done' => $done, ); } /** * Suggested text for the site's privacy policy (admin_init). * * @return void */ public static function add_policy_content(): void { if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { return; } $paragraphs = array( __( 'When this site sends an SMS through smsportal.app (for example about an order or a booking), it stores the phone number, the text of the SMS and its delivery status. Replies to these SMS are stored with the sender\'s number; a reply to an SMS about an order is also added to that order as a private note, which is removed on an erasure request like the rest of this data.', '85pl-android-sms-api' ), __( 'The SMS are sent through the smsportal.app service and the site owner\'s Android phone, over the owner\'s mobile network. smsportal.app receives the phone number and the text of each SMS.', '85pl-android-sms-api' ), __( 'Sent SMS, replies and technical logs are deleted after 90 days. Numbers that replied STOP, or were blocked by the site owner, stay on the block list until the owner removes them, so they get no more SMS.', '85pl-android-sms-api' ), __( 'An export or erasure of personal data requested with an e-mail address covers the SMS and replies of the phone numbers of that person\'s orders, profile and bookings, and the reply notes on that person\'s orders.', '85pl-android-sms-api' ), ); $text = ''; foreach ( $paragraphs as $paragraph ) { $text .= '

' . esc_html( $paragraph ) . '

'; } wp_add_privacy_policy_content( '85.pl | Android SMS API', wp_kses_post( $text ) ); } /** * Sent SMS to the numbers. * * @param array $phones Numbers in E.164. * @param int $offset Rows to skip. * @return array> */ private static function messages( array $phones, int $offset ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, created_at, recipient, message, status, event_label FROM {$wpdb->prefix}smsportal_messages WHERE recipient IN (" . implode( ',', array_fill( 0, count( $phones ), '%s' ) ) . ') ORDER BY id ASC LIMIT %d OFFSET %d', array_merge( $phones, array( self::PAGE_SIZE, $offset ) ) ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * Replies from the numbers. * * @param array $phones Numbers in E.164. * @param int $offset Rows to skip. * @return array> */ private static function replies( array $phones, int $offset ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, received_at, sender, text FROM {$wpdb->prefix}smsportal_replies WHERE sender IN (" . implode( ',', array_fill( 0, count( $phones ), '%s' ) ) . ') ORDER BY id ASC LIMIT %d OFFSET %d', array_merge( $phones, array( self::PAGE_SIZE, $offset ) ) ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * Block list entries of the numbers. * * @param array $phones Numbers in E.164. * @return array> */ private static function blocked( array $phones ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT phone, created_at, source FROM {$wpdb->prefix}smsportal_blocklist WHERE phone IN (" . implode( ',', array_fill( 0, count( $phones ), '%s' ) ) . ')', $phones ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * Delete a batch of sent SMS to the numbers. * * @param array $phones Numbers in E.164. * @return int Rows deleted. */ private static function delete_messages( array $phones ): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table. $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}smsportal_messages WHERE recipient IN (" . implode( ',', array_fill( 0, count( $phones ), '%s' ) ) . ') LIMIT %d', array_merge( $phones, array( self::PAGE_SIZE ) ) ) ); return false === $deleted ? 0 : (int) $deleted; } /** * Delete a batch of replies from the numbers. * * @param array $phones Numbers in E.164. * @return int Rows deleted. */ private static function delete_replies( array $phones ): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table. $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}smsportal_replies WHERE sender IN (" . implode( ',', array_fill( 0, count( $phones ), '%s' ) ) . ') LIMIT %d', array_merge( $phones, array( self::PAGE_SIZE ) ) ) ); return false === $deleted ? 0 : (int) $deleted; } /** * Export item for a sent SMS. * * @param array $row Messages row. * @return array */ private static function message_item( array $row ): array { return array( 'group_id' => 'smsportal-messages', 'group_label' => __( 'SMS sent by this site', '85pl-android-sms-api' ), 'item_id' => 'smsportal-message-' . $row['id'], 'data' => array( self::pair( __( 'Date', '85pl-android-sms-api' ), (string) $row['created_at'] ), self::pair( __( 'Phone number', '85pl-android-sms-api' ), (string) $row['recipient'] ), self::pair( __( 'Message', '85pl-android-sms-api' ), (string) $row['message'] ), self::pair( __( 'Status', '85pl-android-sms-api' ), (string) $row['status'] ), self::pair( __( 'Event', '85pl-android-sms-api' ), (string) $row['event_label'] ), ), ); } /** * Export item for a reply. * * @param array $row Replies row. * @return array */ private static function reply_item( array $row ): array { return array( 'group_id' => 'smsportal-replies', 'group_label' => __( 'SMS replies to this site', '85pl-android-sms-api' ), 'item_id' => 'smsportal-reply-' . $row['id'], 'data' => array( self::pair( __( 'Date', '85pl-android-sms-api' ), (string) $row['received_at'] ), self::pair( __( 'Phone number', '85pl-android-sms-api' ), (string) $row['sender'] ), self::pair( __( 'Message', '85pl-android-sms-api' ), (string) $row['text'] ), ), ); } /** * Export item for a block list entry. * * @param array $row Block list row. * @return array */ private static function block_item( array $row ): array { return array( 'group_id' => 'smsportal-blocklist', 'group_label' => __( 'SMS block list', '85pl-android-sms-api' ), 'item_id' => 'smsportal-block-' . md5( $row['phone'] ), 'data' => array( self::pair( __( 'Phone number', '85pl-android-sms-api' ), $row['phone'] ), self::pair( __( 'Blocked since', '85pl-android-sms-api' ), $row['created_at'] ), self::pair( __( 'Reason', '85pl-android-sms-api' ), 'stop' === $row['source'] ? __( 'Replied with an opt-out word (STOP)', '85pl-android-sms-api' ) : __( 'Blocked by the site owner', '85pl-android-sms-api' ) ), ), ); } /** * Name/value pair of an export item. * * @param string $name Label. * @param string $value Value. * @return array{name:string,value:string} */ private static function pair( string $name, string $value ): array { return array( 'name' => $name, 'value' => $value, ); } }