' . __( 'Suggested text:', 'stock-alibi' ) . ' '
. __( 'This store keeps a log of changes to the stock and prices of its products, so that it can explain why they changed. When a change happens while you are logged in, for example when your order reduces the stock of a product, the log stores your user ID with the change. It does not store your name, email address, postal address or payment details; orders are referred to by their number only.', 'stock-alibi' ),
0 === $days
? __( 'The log is kept until the store deletes it.', 'stock-alibi' )
/* translators: %d: number of days. */
: sprintf( _n( 'Changes are deleted automatically after %d day.', 'Changes are deleted automatically after %d days.', $days, 'stock-alibi' ), $days ),
__( 'You can request an export of the changes linked to your account. If you ask for your personal data to be erased, your user ID is removed from them; the changes themselves stay, without any link to you.', 'stock-alibi' ),
'' . __( 'For the store owner:', 'stock-alibi' ) . ' '
. __( 'For changes made through the REST API, the log also stores the description and the last characters of the WooCommerce API key, or the name of the application password, never the key or password itself. Stock Alibi makes no requests to other services. If an AI provider is set up in WordPress, a shop manager can click "Explain with AI" on a product: WordPress then sends that product\'s recorded changes of the last 90 days to the provider, without user names, customer details or credentials. Mention that provider in this policy if you use the button.', 'stock-alibi' ),
);
return implode( "\n\n", $paragraphs );
}
/**
* Registers the exporter.
*
* @param array> $exporters Exporters.
* @return array>
*/
public function register_exporter( array $exporters ): array {
$exporters[ self::ID ] = array(
'exporter_friendly_name' => __( 'Stock Alibi', 'stock-alibi' ),
'callback' => array( $this, 'export' ),
);
return $exporters;
}
/**
* Registers the eraser.
*
* @param array> $erasers Erasers.
* @return array>
*/
public function register_eraser( array $erasers ): array {
$erasers[ self::ID ] = array(
'eraser_friendly_name' => __( 'Stock Alibi', 'stock-alibi' ),
'callback' => array( $this, 'erase' ),
);
return $erasers;
}
/**
* Exports the changes linked to the user with this email address.
*
* @param string $email Email address.
* @param int $page Page, from 1.
* @return array{data: array>, done: bool}
*/
public function export( string $email, int $page = 1 ): array {
global $wpdb;
$user = get_user_by( 'email', $email );
if ( ! $user ) {
return array(
'data' => array(),
'done' => true,
);
}
$offset = max( 0, $page - 1 ) * self::PER_PAGE;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin's own table; privacy export.
$rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, product_id, sku, field, old_value, new_value, source, source_ref, created_at FROM %i WHERE user_id = %d ORDER BY id LIMIT %d OFFSET %d', Schema::events_table(), $user->ID, self::PER_PAGE, $offset ), ARRAY_A );
$data = array();
foreach ( $rows as $row ) {
$data[] = array(
'group_id' => self::ID,
'group_label' => __( 'Stock and price changes', 'stock-alibi' ),
'item_id' => 'stock-alibi-' . (int) $row['id'],
'data' => array(
array(
'name' => __( 'Time (UTC)', 'stock-alibi' ),
'value' => (string) $row['created_at'],
),
array(
'name' => __( 'Product', 'stock-alibi' ),
'value' => '' !== (string) $row['sku'] ? sprintf( '#%d (%s)', (int) $row['product_id'], $row['sku'] ) : sprintf( '#%d', (int) $row['product_id'] ),
),
array(
'name' => __( 'Field', 'stock-alibi' ),
'value' => Labels::field( (string) $row['field'] ),
),
array(
'name' => __( 'Change', 'stock-alibi' ),
'value' => Labels::value( (string) $row['field'], $row['old_value'] ) . ' → ' . Labels::value( (string) $row['field'], $row['new_value'] ),
),
array(
'name' => __( 'Source', 'stock-alibi' ),
'value' => '' !== (string) $row['source_ref'] ? Labels::source( (string) $row['source'] ) . ': ' . $row['source_ref'] : Labels::source( (string) $row['source'] ),
),
),
);
}//end foreach
return array(
'data' => $data,
'done' => count( $rows ) < self::PER_PAGE,
);
}
/**
* Removes the user ID, and the name of the user's application password,
* from the changes linked to the user with this email address.
*
* Rows that are anonymized no longer match, so every call takes the next
* batch from the start; the page number is not needed.
*
* @param string $email Email address.
* @param int $page Page (unused).
* @return array{items_removed: bool, items_retained: bool, messages: string[], done: bool}
*/
public function erase( string $email, int $page = 1 ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Signature of the privacy eraser callback.
global $wpdb;
$result = array(
'items_removed' => false,
'items_retained' => false,
'messages' => array(),
'done' => true,
);
$user = get_user_by( 'email', $email );
if ( ! $user ) {
return $result;
}
$table = Schema::events_table();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin's own table; privacy erasure.
$rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, context FROM %i WHERE user_id = %d ORDER BY id LIMIT %d', $table, $user->ID, self::PER_PAGE ), ARRAY_A );
if ( ! $rows ) {
return $result;
}
foreach ( $rows as $row ) {
$context = null === $row['context'] ? null : json_decode( (string) $row['context'], true );
if ( is_array( $context ) && isset( $context['app_password'] ) ) {
unset( $context['app_password'] );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin's own table; privacy erasure.
$wpdb->update( $table, array( 'context' => wp_json_encode( (object) $context ) ), array( 'id' => (int) $row['id'] ), array( '%s' ), array( '%d' ) );
}
}
$ids = array_map( static fn( array $row ): int => (int) $row['id'], $rows );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Plugin's own table; one %d per ID.
$wpdb->query( $wpdb->prepare( 'UPDATE %i SET user_id = NULL WHERE id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', $table, ...$ids ) );
$result['items_removed'] = true;
$result['done'] = count( $rows ) < self::PER_PAGE;
if ( $result['done'] ) {
$result['messages'][] = __( 'The stock and price changes themselves were kept, without the link to the user.', 'stock-alibi' );
}
return $result;
}
}