Export/Erase Personal Data). * * The plugin sends shopper behavioural events to the brain (try-on / view / * add-to-cart / checkout), keyed by the WP user id (`customerId`) and WooCommerce * order ids (`orderId`). When a site admin runs WordPress's built-in personal-data * export or erasure for a shopper's email, these hooks resolve that email to its WP * user id + WooCommerce order ids and call the brain's privacy endpoints so the data * the brain holds is exported / erased in lock-step with the rest of WordPress. * * Brain contract (server-to-server, authenticated with the SERVER-ONLY secret key, * same X-Aisthetix-Secret header used by Status/Connect/Attribution): * POST {BRAIN}/api/privacy/export { customerId, orderIds[] } * -> { count, events:[...], skipped } * POST {BRAIN}/api/privacy/erase { customerId, orderIds[] } * -> { deleted, skipped } * * FAILURE TOLERANCE: a slow/unreachable brain must never fatal a WordPress privacy * request. On any transport/HTTP error the exporter returns no data (done) and the * eraser reports items_retained with a message, so the WP admin sees a soft warning * rather than a hard failure, and can retry. * * @package Aisthetix\TryOn */ namespace Aisthetix\TryOn; defined( 'ABSPATH' ) || exit; /** * Registers the personal-data exporter and eraser with WordPress core. */ class Privacy { /** * Export/erase group id (WordPress groups exported/erased items by this key). * * @var string */ const GROUP_ID = 'aisthetix-tryon'; /** * Register the exporter + eraser hooks. * * @return void */ public function init() { add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_exporter' ) ); add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_eraser' ) ); } /** * Register the aisthetix exporter with core. * * @param array $exporters Registered exporters. * @return array */ public function register_exporter( $exporters ) { $exporters['aisthetix-tryon'] = array( 'exporter_friendly_name' => __( 'aisthetix Virtual Try-On', 'aisthetix-virtual-try-on' ), 'callback' => array( $this, 'export' ), ); return $exporters; } /** * Register the aisthetix eraser with core. * * @param array $erasers Registered erasers. * @return array */ public function register_eraser( $erasers ) { $erasers['aisthetix-tryon'] = array( 'eraser_friendly_name' => __( 'aisthetix Virtual Try-On', 'aisthetix-virtual-try-on' ), 'callback' => array( $this, 'erase' ), ); return $erasers; } /** * Export the shopper's try-on events held by the brain. * * WordPress calls this paginated; the brain returns the full subject export in * one response, so we serve everything on page 1 and report `done` immediately. * * @param string $email_address Shopper email being exported. * @param int $page 1-based page number (unused — single page). * @return array { data: array, done: bool } */ public function export( $email_address, $page = 1 ) { $empty = array( 'data' => array(), 'done' => true, ); if ( ! Plugin::is_connected() ) { return $empty; } $subject = $this->resolve_subject( $email_address ); if ( null === $subject ) { return $empty; } $result = $this->call_brain( '/api/privacy/export', $subject ); if ( is_wp_error( $result ) || ! is_array( $result ) ) { // Soft-fail: nothing to export this run (admin can re-run). Never fatal. return $empty; } $events = isset( $result['events'] ) && is_array( $result['events'] ) ? $result['events'] : array(); $export_items = array(); foreach ( $events as $index => $event ) { if ( ! is_array( $event ) ) { continue; } $export_items[] = array( 'group_id' => self::GROUP_ID, 'group_label' => __( 'aisthetix Virtual Try-On events', 'aisthetix-virtual-try-on' ), 'item_id' => 'aisthetix-event-' . $index, 'data' => $this->event_to_export_data( $event ), ); } return array( 'data' => $export_items, 'done' => true, ); } /** * Erase the shopper's try-on events held by the brain. * * @param string $email_address Shopper email being erased. * @param int $page 1-based page number (unused — single page). * @return array { items_removed: bool, items_retained: bool, messages: array, done: bool } */ public function erase( $email_address, $page = 1 ) { $response = array( 'items_removed' => false, 'items_retained' => false, 'messages' => array(), 'done' => true, ); if ( ! Plugin::is_connected() ) { // Nothing we can do without keys; report cleanly (nothing removed/retained). return $response; } $subject = $this->resolve_subject( $email_address ); if ( null === $subject ) { // No WP user / no orders for this email -> the brain holds nothing keyed // to this subject. Report a clean no-op. return $response; } $result = $this->call_brain( '/api/privacy/erase', $subject ); if ( is_wp_error( $result ) || ! is_array( $result ) ) { // Failure-tolerant: do NOT fatal. Tell WordPress the data was retained so // the admin sees a warning and can retry, rather than a false "removed". $response['items_retained'] = true; $response['messages'][] = __( 'aisthetix Virtual Try-On data could not be erased because the aisthetix service was unreachable. Please retry.', 'aisthetix-virtual-try-on' ); return $response; } // We resolved a subject and sent it, but the brain reports it could not key the // request to any data (skipped). Surface a note instead of a silent clean pass — // the admin should know nothing was matched (vs. "removed"). items_removed stays // false; this is NOT a transport failure, so we do not set items_retained. if ( ! empty( $result['skipped'] ) ) { $response['messages'][] = __( 'aisthetix Virtual Try-On data could not be matched to this person, so nothing was removed.', 'aisthetix-virtual-try-on' ); return $response; } $deleted = isset( $result['deleted'] ) ? (int) $result['deleted'] : 0; if ( $deleted > 0 ) { $response['items_removed'] = true; } return $response; } /** * Resolve an email address to the brain subject keys: the WP user id * (`customerId`) and the shopper's WooCommerce order ids (`orderIds`). * * Mirrors how attribution keys events: customerId is the logged-in WP user id; * orderIds are the orders placed by that email (covers guest orders too, which * carry no user id but are matched by order id on the brain). Returns null when * NEITHER a user nor any order exists for the email — there is then no subject the * brain could hold, and the brain would SKIP a keyless request anyway. * * @param string $email_address Shopper email. * @return array|null { customerId: string|null, orderIds: string[] } or null. */ private function resolve_subject( $email_address ) { $email_address = is_string( $email_address ) ? trim( $email_address ) : ''; if ( '' === $email_address || ! is_email( $email_address ) ) { return null; } // customerId: the WP user id behind this email, when the shopper has an account. $customer_id = null; $user = get_user_by( 'email', $email_address ); if ( $user instanceof \WP_User && $user->ID > 0 ) { $customer_id = (string) $user->ID; } // orderIds: every WooCommerce order placed under this email (incl. guests). $order_ids = $this->order_ids_for_email( $email_address ); if ( null === $customer_id && empty( $order_ids ) ) { return null; } return array( 'customerId' => $customer_id, 'orderIds' => $order_ids, ); } /** * Collect the WooCommerce order ids associated with a billing email. * * Uses wc_get_orders() with the `customer` arg (matches the billing email and, * for registered users, their user id) — HPOS-safe and avoids touching order * tables directly. Returns string ids to match how the brain stores `orderId`. * * @param string $email_address Billing email. * @return string[] Order ids as strings (possibly empty). */ private function order_ids_for_email( $email_address ) { if ( ! function_exists( 'wc_get_orders' ) ) { return array(); } $ids = wc_get_orders( array( 'customer' => $email_address, 'limit' => -1, 'return' => 'ids', ) ); if ( ! is_array( $ids ) ) { return array(); } $order_ids = array(); foreach ( $ids as $id ) { $id = (int) $id; if ( $id > 0 ) { $order_ids[] = (string) $id; } } return array_values( array_unique( $order_ids ) ); } /** * Flatten a single brain event document into WordPress export name/value rows. * * Only the human-meaningful, present fields are surfaced; nulls/empties are * skipped to keep the export readable. * * @param array $event One event document from the brain export. * @return array[] List of { name, value } rows. */ private function event_to_export_data( $event ) { $fields = array( 'eventName' => __( 'Event', 'aisthetix-virtual-try-on' ), 'productId' => __( 'Product ID', 'aisthetix-virtual-try-on' ), 'orderId' => __( 'Order ID', 'aisthetix-virtual-try-on' ), 'value' => __( 'Order value', 'aisthetix-virtual-try-on' ), 'currency' => __( 'Currency', 'aisthetix-virtual-try-on' ), 'url' => __( 'Page URL', 'aisthetix-virtual-try-on' ), 'occurredAt' => __( 'Occurred at', 'aisthetix-virtual-try-on' ), 'receivedAt' => __( 'Recorded at', 'aisthetix-virtual-try-on' ), ); $rows = array(); foreach ( $fields as $key => $label ) { if ( ! isset( $event[ $key ] ) || '' === $event[ $key ] || null === $event[ $key ] ) { continue; } $raw = $event[ $key ]; if ( is_array( $raw ) ) { $raw = implode( ', ', array_map( 'strval', $raw ) ); } $rows[] = array( 'name' => $label, 'value' => (string) $raw, ); } // productIds[] (checkout line items) as a single readable row, when present. if ( isset( $event['productIds'] ) && is_array( $event['productIds'] ) && ! empty( $event['productIds'] ) ) { $rows[] = array( 'name' => __( 'Product IDs', 'aisthetix-virtual-try-on' ), 'value' => implode( ', ', array_map( 'strval', $event['productIds'] ) ), ); } return $rows; } /** * Server-to-server POST to a brain privacy endpoint with the subject keys. * * Authenticated with the SERVER-ONLY secret key (never exposed to the browser), * exactly like {@see Status} and {@see Attribution}. Returns the decoded JSON * body on a 200, or a WP_Error on any transport/HTTP/parse failure so callers can * degrade gracefully. * * @param string $path Brain path, e.g. '/api/privacy/erase'. * @param array $subject { customerId, orderIds } subject keys. * @return array|\WP_Error Decoded response body, or WP_Error on failure. */ private function call_brain( $path, array $subject ) { $response = wp_remote_post( Plugin::brain_url() . $path, array( 'timeout' => 15, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'X-Aisthetix-Secret' => Plugin::secret_key(), ), 'body' => wp_json_encode( array( 'customerId' => $subject['customerId'], 'orderIds' => $subject['orderIds'], ) ), ) ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code || ! is_array( $data ) ) { return new \WP_Error( 'aisthetix_privacy_http', /* translators: %d: HTTP status code returned by the aisthetix service. */ sprintf( __( 'aisthetix service returned HTTP %d', 'aisthetix-virtual-try-on' ), $code ), array( 'status' => $code ) ); } return $data; } }