<?php declare(strict_types = 1);
namespace MailPoet\WooCommerce;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\WP\Functions as WPFunctions;
use WC_Order;
/**
* WordPress privacy integration for the MailPoet attribution data stored in Woo
* order meta (STOMAIL-8137). Woo's own exporter, eraser, and order anonymization
* do not cover _wc_order_attribution_* meta, so without this the MailPoet
* identifiers would survive as orphaned personal data. subscriber_id and click_id
* identify a person and are removed; newsletter_id and queue_id are campaign-level
* and are kept so anonymized orders stay attributable to a campaign.
*/
class OrderAttributionPrivacy {
const LIMIT = 100;
const PERSONAL_FIELD_NAMES = [
OrderAttributionFields::FIELD_CLICK_ID,
OrderAttributionFields::FIELD_SUBSCRIBER_ID,
];
const SUBSCRIBER_ID_QUERY_VAR = 'mailpoet_attribution_subscriber_id';
/** @var WPFunctions */
private $wp;
/** @var Helper */
private $wooHelper;
/** @var SubscribersRepository */
private $subscribersRepository;
public function __construct(
WPFunctions $wp,
Helper $wooHelper,
SubscribersRepository $subscribersRepository
) {
$this->wp = $wp;
$this->wooHelper = $wooHelper;
$this->subscribersRepository = $subscribersRepository;
}
/**
* @param mixed $email
* @param mixed $page
*/
public function export($email, $page = 1): array {
$data = [];
$orders = $this->getAttributedOrders($email, is_numeric($page) ? max(1, (int)$page) : 1);
foreach ($orders as $order) {
$data[] = $this->getOrderData($order);
}
return [
'data' => $data,
'done' => count($orders) < self::LIMIT,
];
}
/**
* Always reads the first page: erasing removes the subscriber_id meta the
* query matches on, so the result set shrinks with every pass and WordPress
* keeps calling until done.
*
* @param mixed $email
* @param mixed $page
*/
public function erase($email, $page = 1): array {
$orders = $this->getAttributedOrders($email, 1);
foreach ($orders as $order) {
$this->removeOrderPersonalData($order);
}
return [
'items_removed' => count($orders) > 0,
'items_retained' => false,
'messages' => [],
'done' => count($orders) < self::LIMIT,
];
}
/**
* Also hooked to woocommerce_privacy_remove_order_personal_data so the
* MailPoet identifiers are removed when an order is anonymized.
*
* @param mixed $order
*/
public function removeOrderPersonalData($order): void {
if (!$order instanceof WC_Order) {
return;
}
foreach (self::PERSONAL_FIELD_NAMES as $fieldName) {
$order->delete_meta_data(OrderAttributionFields::getMetaKey($fieldName));
}
// The reconciliation record (STOMAIL-8136) embeds the same click identifiers.
$order->delete_meta_data(OrderAttributionReconciler::RECONCILIATION_META_KEY);
$order->save_meta_data();
}
/**
* @param mixed $email
* @return WC_Order[]
*/
private function getAttributedOrders($email, int $page): array {
if (!is_string($email) || trim($email) === '' || !$this->wooHelper->isWooCommerceActive()) {
return [];
}
$subscriber = $this->subscribersRepository->findOneBy(['email' => trim($email)]);
if (!$subscriber instanceof SubscriberEntity) {
return [];
}
$args = [
'limit' => self::LIMIT,
'paged' => $page,
'orderby' => 'ID',
'order' => 'ASC',
];
$subscriberId = (string)$subscriber->getId();
if ($this->wooHelper->isWooCommerceCustomOrdersTableEnabled()) {
$args['meta_query'] = [
[
'key' => OrderAttributionFields::getMetaKey(OrderAttributionFields::FIELD_SUBSCRIBER_ID),
'value' => $subscriberId,
],
];
$orders = $this->wooHelper->wcGetOrders($args);
} else {
// The legacy posts datastore rejects meta_query with a doing-it-wrong
// notice; a custom query var translated through this extension point is
// WooCommerce's documented way to filter by meta there. The filter is
// scoped to this query so it cannot affect other order queries.
$args[self::SUBSCRIBER_ID_QUERY_VAR] = $subscriberId;
$this->wp->addFilter(
'woocommerce_order_data_store_cpt_get_orders_query',
[$this, 'translateSubscriberIdQueryVar'],
10,
2
);
try {
$orders = $this->wooHelper->wcGetOrders($args);
} finally {
$this->wp->removeFilter(
'woocommerce_order_data_store_cpt_get_orders_query',
[$this, 'translateSubscriberIdQueryVar'],
10
);
}
}
if (!is_array($orders)) {
return [];
}
return array_values(array_filter($orders, function ($order) {
return $order instanceof WC_Order;
}));
}
/**
* @param mixed $query
* @param mixed $queryVars
* @return mixed
*/
public function translateSubscriberIdQueryVar($query, $queryVars) {
if (!is_array($query) || !is_array($queryVars)) {
return $query;
}
$subscriberId = $queryVars[self::SUBSCRIBER_ID_QUERY_VAR] ?? null;
if (!is_scalar($subscriberId) || (string)$subscriberId === '') {
return $query;
}
$metaQuery = isset($query['meta_query']) && is_array($query['meta_query']) ? $query['meta_query'] : [];
$metaQuery[] = [
'key' => OrderAttributionFields::getMetaKey(OrderAttributionFields::FIELD_SUBSCRIBER_ID),
'value' => (string)$subscriberId,
];
$query['meta_query'] = $metaQuery;
return $query;
}
private function getOrderData(WC_Order $order): array {
$fieldLabels = [
OrderAttributionFields::FIELD_CLICK_ID => __('Email click ID', 'mailpoet'),
OrderAttributionFields::FIELD_NEWSLETTER_ID => __('Email ID', 'mailpoet'),
OrderAttributionFields::FIELD_QUEUE_ID => __('Sending queue ID', 'mailpoet'),
OrderAttributionFields::FIELD_SUBSCRIBER_ID => __('Subscriber ID', 'mailpoet'),
];
$data = [
[
'name' => __('Order number', 'mailpoet'),
'value' => $order->get_order_number(),
],
];
foreach ($fieldLabels as $fieldName => $label) {
$value = $order->get_meta(OrderAttributionFields::getMetaKey($fieldName));
if (!is_scalar($value) || (string)$value === '') {
continue;
}
$data[] = [
'name' => $label,
'value' => (string)$value,
];
}
// The reconciliation record (STOMAIL-8136) embeds click identifiers, so it
// is erased as personal data and must be part of the export as well.
$reconciliation = $order->get_meta(OrderAttributionReconciler::RECONCILIATION_META_KEY);
if (is_scalar($reconciliation) && (string)$reconciliation !== '') {
$data[] = [
'name' => __('Attribution reconciliation record', 'mailpoet'),
'value' => (string)$reconciliation,
];
}
return [
'group_id' => 'mailpoet-woocommerce-order-attribution',
'group_label' => __('MailPoet WooCommerce Order Attribution', 'mailpoet'),
'item_id' => 'order-' . $order->get_id(),
'data' => $data,
];
}
}