<?php
|
namespace JVBase\integrations;
|
|
use Exception;
|
use JVBase\meta\Meta;
|
use JVBase\meta\Sanitizer;
|
use JVBase\registrar\Registrar;
|
use WP_Error;
|
use WP_Post;
|
use WP_Term;
|
use WP_User;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
trait SyncTo {
|
use SyncHelpers, UserConnection;
|
/**
|
* Must be defined according to how each service needs it to be
|
* @param int $itemID
|
* @param string $type
|
* @return array
|
* @throws Exception
|
*/
|
abstract protected function formatForService(int $itemID, string $type = 'post'):array;
|
|
/**
|
* Defaults to an array of formatted items. Can be overridden in child classes
|
* @param array $itemIDs
|
* @param string $type
|
* @return array
|
*/
|
protected function formatItems(array $itemIDs, string $type):array
|
{
|
$items = [];
|
foreach ($itemIDs as $ID) {
|
try {
|
$items[] = $this->formatForService($ID, $type);
|
} catch (Exception $e){
|
$this->logError('formatItems', 'Could not format Item for service',[
|
'itemID' => $ID,
|
'type' => $type,
|
'message' => $e->getMessage(),
|
]);
|
}
|
}
|
return $items;
|
}
|
/***************************************************************
|
* Item creation
|
***************************************************************/
|
public function createBatchToService($data):array
|
{
|
$type = $data['type']??'post';
|
|
//Check if any of the submitted ids are already created
|
$created = array_filter($data['items'],
|
function($ID) use ($type) {
|
return !empty($this->getServiceItemID($ID, $type));
|
});
|
|
$updated = [];
|
if (!empty($created)) {
|
$updateData = $data;
|
$updateData['items'] = $created;
|
$updated = $this->updateBatchToService($data);
|
|
//remove any updated items from the original items to process
|
$data['items'] = array_filter($data['items'], function ($ID) use ($created) {
|
return !in_array($ID, $created);
|
});
|
}
|
|
$items = $this->formatItems($data['items'], $type);
|
if (empty($items)) {
|
return $this->noCreatedItems($updated);
|
}
|
|
if ($this->hasBatchCreate) {
|
$response = $this->sendBatchCreate($items);
|
if (!is_wp_error($response)) {
|
$result = $this->processBatchCreateResponse($data, $response);
|
} else {
|
$this->logError('createBatchToService','Batch create failed',[
|
'method' => 'createBatchToService',
|
'item_ids' => $data['items'],
|
'error' => $response
|
]);
|
$this->updateItemStatus($data['items'], 'error');
|
|
$result = [
|
'outcome' => 'failed_permanent',
|
'result' => 'Could not update items'
|
];
|
}
|
} else {
|
$success = $errors = [];
|
foreach ($items as $item) {
|
$itemResult = $this->createOne($item);
|
if (!is_wp_error($itemResult)) {
|
$success[] = $itemResult;
|
} else {
|
$errors[] = $itemResult;
|
}
|
}
|
$result = [
|
'outcome' => empty($errors) ? 'success' : (empty($success) ? 'failed' : 'partial'),
|
'result' => [
|
'success' => $success,
|
'errors' => $errors
|
]
|
];
|
}
|
|
return array_merge($result, $updated);
|
}
|
|
/**
|
* To be implemented by integration extension. Updates a single item
|
* @param array $item
|
* @return array|WP_Error
|
*/
|
protected function createOne(array $item):array|WP_Error
|
{
|
return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own createOne');
|
}
|
|
/**
|
* Overridden by child classes
|
* @param array $items
|
* @return array|WP_Error
|
*/
|
protected function sendBatchCreate(array $items):array|WP_Error
|
{
|
return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own sendBatchCreate');
|
}
|
|
/**
|
* To be implemented by extensions
|
* @param array $data
|
* @param array $response
|
* @return array
|
*/
|
protected function processBatchCreateResponse(array $data, array $response):array
|
{
|
return [
|
'outcome' => 'failed',
|
'result' => [
|
'message' => $this->service_name.' should implement processBatchCreateResponse.'
|
]
|
];
|
}
|
|
/*****************************************************************
|
* ITEM UPDATING
|
*****************************************************************/
|
public function updateBatchToService(array $data):array
|
{
|
$type = $data['type']??'post';
|
|
$newlyCreated = [];
|
if (!$this->canCreateOnUpdate) {
|
$created = array_filter($data['items'], function($ID) use ($type) {
|
return !empty($this->getServiceItemID($ID, $type));
|
});
|
|
//Test to see if we have any that haven't been created yet.
|
//For some services, items may have to be created before they can be updated
|
if (count($created) !== count($data['items'])) {
|
$notCreated = array_filter($data['items'], function($ID) use ($type) {
|
return empty($this->getServiceItemID($ID, $type));
|
});
|
$newData = $data;
|
$newData['items'] = $notCreated;
|
$newlyCreated = $this->createBatchToService($newData);
|
}
|
|
// If we don't have any that are created, just send the noUpdatedItems response, with any newly created items added
|
if (empty($created)) {
|
return $this->noUpdatedItems($newlyCreated);
|
}
|
$data['items'] = $created;
|
}
|
|
|
$items = $this->formatItems($data['items'], $type);
|
|
if (empty($items)) {
|
return $this->noUpdatedItems($newlyCreated);
|
}
|
|
if ($this->hasBatchUpdate) {
|
$response = $this->sendBatchUpdate($items);
|
if (!is_wp_error($response)) {
|
$result = $this->processBatchUpdateResponse($data, $response);
|
} else {
|
$this->logError('updateBatchToService','Batch update failed',[
|
'method' => 'updateBatchToService',
|
'item_ids' => $data['items'],
|
'error' => $response
|
]);
|
$this->updateItemStatus($data['items'], 'error');
|
|
$result = [
|
'outcome' => 'failed_permanent',
|
'result' => 'Could not update items'
|
];
|
}
|
} else {
|
$success = $errors = [];
|
//Does not have batch update, manually update each one
|
foreach ($items as $item) {
|
$itemResult = $this->updateOne($item);
|
if (!is_wp_error($itemResult)) {
|
$success[] = $itemResult;
|
} else {
|
$errors[] = $itemResult;
|
}
|
}
|
|
$result = [
|
'outcome' => empty($errors) ? 'success' : (empty($success) ? 'failed' : 'partial'),
|
'result' => [
|
'success' => $success,
|
'errors' => $errors
|
]
|
];
|
}
|
|
return array_merge($result, $newlyCreated);
|
}
|
|
/**
|
* To be implemented by integration extension. Updates a single item
|
* @param array $item
|
* @return array|WP_Error
|
*/
|
protected function updateOne(array $item):array|WP_Error
|
{
|
return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own updateOne method');
|
}
|
|
/**
|
* Overridden by child classes
|
* @param array $items
|
* @return array|WP_Error
|
*/
|
protected function sendBatchUpdate(array $items):array|WP_Error
|
{
|
return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own sendBatchUpdate');
|
}
|
|
|
/**
|
* To be implemented by extensions
|
* @param array $data
|
* @param array $response
|
* @return array
|
*/
|
protected function processBatchUpdateResponse(array $data, array $response):array
|
{
|
return [
|
'outcome' => 'failed',
|
'result' => [
|
'message' => $this->service_name.' should implement processBatchUpdateResponse.'
|
]
|
];
|
}
|
/*****************************************************************
|
* UTILITY
|
*****************************************************************/
|
protected function noUpdatedItems(array $created):array
|
{
|
$result = [
|
'outcome' => 'success',
|
'result' => [
|
'message' => 'No items to update',
|
'updated' => [],
|
'errors' => [],
|
]
|
];
|
|
if (!empty($created)) {
|
error_log('Result before: '.print_r($result, true));
|
$result = array_merge($result, $created);
|
error_log('Result after merge: '.print_r($result, true));
|
}
|
return $result;
|
}
|
|
protected function noCreatedItems(array $updated):array
|
{
|
$result = [
|
'outcome' => 'success',
|
'result' => [
|
'message' => 'No items to create',
|
'created' => [],
|
'errors' => [],
|
]
|
];
|
if (!empty($updated)) {
|
$result = array_merge($result, $updated);
|
}
|
return $result;
|
}
|
|
/****************************************************************
|
* UTILITY
|
****************************************************************/
|
protected function getSyncFields(int $itemID, string $type, array $additionalFields = []):array
|
{
|
$meta = match ($type) {
|
'post' => Meta::forPost($itemID),
|
'term' => Meta::forTerm($itemID),
|
'user' => Meta::forUser($itemID),
|
default => false
|
};
|
if (!$meta) {
|
return [];
|
}
|
|
$fields = [
|
'share_to_' . $this->service_name,
|
'_keep_synced_' . $this->service_name,
|
"_{$this->service_name}_item_id",
|
"_{$this->service_name}_last_sync",
|
"_{$this->service_name}_shared_at",
|
"_{$this->service_name}_sync_status",
|
"_{$this->service_name}_scheduled_at",
|
... $additionalFields
|
];
|
return $meta->getAll($fields);
|
}
|
/****************************************************************
|
* POST SYNC
|
****************************************************************/
|
public function addSavePost():void
|
{
|
if (!has_action('save_post', [$this, 'handleSavePost'])) {
|
add_action('save_post', [$this, 'handleSavePost'], 20, 3);
|
}
|
}
|
public function removeSavePost():void
|
{
|
remove_action('save_post', [$this, 'handleSavePost'], 20, 3);
|
}
|
public function handleSavePost(int $postID, WP_Post $post, bool $update):void
|
{
|
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
|
if (wp_is_post_revision($postID)) return;
|
|
error_log('=== ['.$this->service_name.']::handleSavePost called');
|
|
$postType = jvbNoBase($post->post_type);
|
if (!in_array($postType, $this->syncPostTypes)) {
|
error_log('Not handling save for '.$this->service_name.' because there are no syncPostTypes: '.print_r($this->syncPostTypes, true));
|
return;
|
}
|
|
$registrar = Registrar::getInstance($postType);
|
//Should not happen, as syncPostTypes is defined by Registrar instances
|
if (!$registrar) {
|
return;
|
}
|
|
$settings = $registrar->getIntegrationConfig($this->service_name);
|
if (!$settings) {
|
error_log('Not handling save for '.$this->service_name.' because of no integration config '.print_r($settings, true));
|
return;
|
}
|
|
$fields = $this->getSyncFields($postID, 'post');
|
if (!$fields['share_to_'.$this->service_name]) {
|
error_log('Not handling save for '.$this->service_name.' because of no share_to_'.$this->service_name.' '.print_r($fields, true));
|
return;
|
}
|
|
$isShared = array_key_exists("_{$this->service_name}_item_id", $fields) && !empty($fields["_{$this->service_name}_item_id"]);
|
|
if ($post->post_status !== 'publish' && !$isShared) {
|
error_log('Not handling save for '.$this->service_name.' because post status is not publish, and it is not already shared.');
|
return;
|
}
|
|
if ($isShared && $update && !$fields['_keep_synced_'.$this->service_name]) {
|
error_log('Not handling save for '.$this->service_name.' because it is already shared, and not set to keep synced. ');
|
return;
|
}
|
error_log('==== Sending to integration\'s handleTheSavePost '.$this->service_name.' ====');
|
$this->removeSavePost();
|
$this->handleTheSavePost($postID, $post, $update, $settings);
|
$this->addSavePost();
|
}
|
/**
|
* Handle post save for syncing
|
*
|
* Override to implement custom sync logic when posts are saved.
|
* Check the $settings array for post type specific configuration.
|
*
|
* @param int $postID The post ID
|
* @param WP_Post $post The post object
|
* @param bool $update Whether this is an update
|
* @param array $settings Post type integration settings
|
* @return void
|
*/
|
protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings):void
|
{
|
error_log('==== ['.$this->title.']::handleTheSavePost ====');
|
$this->queueOperation(self::$syncTo, [
|
'posts' => [$postID],
|
'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author
|
], [
|
'priority' => 'high',
|
'delay' => 30,
|
]);
|
Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued');
|
}
|
|
public function addDeletePost():void
|
{
|
if (!has_action('before_delete_post', [$this, 'handleDeletePost'])) {
|
add_action('before_delete_post', [$this, 'handleDeletePost'], 20, 3);
|
}
|
}
|
public function removeDeletePost():void
|
{
|
remove_action('before_delete_post', [$this, 'handleSavePost'], 20, 3);
|
}
|
public function handleDeletePost(int $postID):void
|
{
|
if (!$this->canSync['delete']) {
|
return;
|
}
|
$postType = get_post_type($postID);
|
if (!in_array(jvbNoBase($postType), $this->syncPostTypes)) {
|
return;
|
}
|
$fields = $this->getSyncFields($postID, 'post');
|
if (empty($fields["_{$this->service_name}_item_id"])) {
|
return;
|
}
|
$post = get_post($postID);
|
if (!$post) {
|
return;
|
}
|
$userID = $this->determineUserID($post->post_author);
|
if (!$userID) {
|
return;
|
}
|
|
JVB()->queue()->add(
|
self::$deleteFrom,
|
$userID,
|
[
|
'fields' => [$postID => $fields],
|
'service' => $this->service_name,
|
'type' => 'post'
|
]
|
);
|
}
|
/****************************************************************
|
* TERM SYNC
|
****************************************************************/
|
public function addSaveTerm():void
|
{
|
if (empty($this->syncTaxonomies)) {
|
return;
|
}
|
if (!has_action('saved_term', [$this, 'handleSaveTerm'])) {
|
add_action('saved_term', [$this, 'handleSaveTerm'], 20, 3);
|
}
|
}
|
public function removeSaveTerm():void
|
{
|
if (empty($this->syncTaxonomies)) {
|
return;
|
}
|
remove_action('saved_term', [$this, 'handleSaveTerm'], 20, 3);
|
}
|
protected function handleSaveTerm(int $termID, int $tt_id, string $taxonomy, bool $update, array $args):void
|
{
|
$tax = jvbNoBase($taxonomy);
|
if (!in_array($tax, $this->syncTaxonomies)) {
|
return;
|
}
|
$registrar = Registrar::getInstance($tax);
|
if (!$registrar) {
|
return;
|
}
|
|
$settings = $registrar->getIntegrationConfig($this->service_name);
|
if (!$settings) {
|
return;
|
}
|
|
$fields = $this->getSyncFields($termID, 'term');
|
if (!$fields['share_to_'.$this->service_name]) {
|
return;
|
}
|
|
$isShared = array_key_exists("_{$this->service_name}_item_id", $fields) && !empty($fields["_{$this->service_name}_item_id"]);
|
|
if ($isShared && $update && !$fields['_keep_synced_'.$this->service_name]) {
|
return;
|
}
|
$this->removeSaveTerm();
|
$this->handleTheSaveTerm($termID, $update, $taxonomy, $settings);
|
$this->addSaveTerm();
|
}
|
|
/**
|
* @param int $termID
|
* @param bool $update
|
* @param array $settings
|
* @return void
|
*/
|
protected function handleTheSaveTerm(int $termID, bool $update, string $taxonomy, array $settings):void
|
{
|
error_log('==== ['.$this->title.']::handleTheSaveTerm ====');
|
//TODO: Figure out some sort of permissions for if the user can share this term, particularly for content types
|
//TODO: If this is a content type, it likely has its own integration. This is an edmonton.ink problem, so I'm offloading it for now
|
$this->queueOperation(self::$syncTo, [
|
'terms' => [$termID],
|
'user' => get_current_user_id(),
|
], [
|
'priority' => 'high',
|
'delay' => 30,
|
]);
|
Meta::forTerm($termID)->set('_'.$this->service_name.'_sync_status', 'queued');
|
}
|
|
public function addDeleteTerm():void
|
{
|
if (!has_action('pre_delete_term', [$this, 'handleDeleteTerm'])) {
|
add_action('pre_delete_term', [$this, 'handleDeleteTerm']);
|
}
|
}
|
public function removeDeleteTerm():void
|
{
|
remove_action('pre_delete_term', [$this, 'handleDeleteTerm']);
|
}
|
public function handleDeleteTerm(int $termID, string $taxonomy):void
|
{
|
if (!$this->canSync['delete']) {
|
return;
|
}
|
$tax = jvbNoBase($taxonomy);
|
if (!in_array($tax, $this->syncTaxonomies)) {
|
return;
|
}
|
$fields = $this->getSyncFields($termID, 'term');
|
if (empty($fields["_{$this->service_name}_item_id"])) {
|
return;
|
}
|
JVB()->queue()->add(
|
self::$deleteFrom,
|
0,
|
[
|
'fields' => [$termID => $fields],
|
'service' => $this->service_name,
|
'type' => 'term'
|
]
|
);
|
}
|
|
/****************************************************************
|
* USER SYNC
|
****************************************************************/
|
public function addSaveUser():void
|
{
|
if (empty($this->syncUsers)) {
|
return;
|
}
|
if (!has_action('profile_update', [$this, 'handleUpdateUser'])) {
|
add_action('profile_update', [$this, 'handleUpdateUser'], 20, 1);
|
}
|
if (!has_action('user_register', [$this, 'handleUpdateUser'])) {
|
add_action('user_register', [$this, 'handleUpdateUser'], 20, 1);
|
}
|
}
|
public function removeSaveUser():void
|
{
|
if (empty($this->syncUsers)) {
|
return;
|
}
|
remove_action('profile_update', [$this, 'handleUpdateUser'], 20);
|
remove_action('user_register', [$this, 'handleUpdateUser'], 20);
|
}
|
protected function handleUpdateUser(int $userID):void
|
{
|
$user = $this->getOrCreateUser($userID);
|
if (!$user) {
|
return;
|
}
|
|
$fields = $this->getSyncFields($userID, 'user');
|
if (!empty($fields["_{$this->service_name}_item_id"])) {
|
return;
|
}
|
|
$this->removeSaveUser();
|
$this->handleTheSaveUser($userID);
|
$this->addSaveTerm();
|
}
|
|
protected function getOrCreateUser(int $userID):string|false
|
{
|
$user = get_userdata($userID);
|
if (!$user || is_wp_error($user)) {
|
return false;
|
}
|
$role = jvbUserRole($userID);
|
if (!in_array(jvbNoBase($role), $this->syncUsers)) {
|
return false;
|
}
|
$fields = $this->getSyncFields($userID, 'user');
|
if (!empty($fields["_{$this->service_name}_item_id"])) {
|
return $fields["_{$this->service_name}_item_id"];
|
}
|
|
$meta = Meta::forUser($userID);
|
$serviceUserID = $this->searchServiceForUser($user->user_email??'');
|
if ($serviceUserID) {
|
$meta->set("{$this->service_name}_item_id", $serviceUserID);
|
return $serviceUserID;
|
}
|
|
$created = $this->createServiceUser($userID, $fields);
|
if ($created) {
|
return $created;
|
}
|
return false;
|
}
|
public function searchServiceForUser(string $email):string|false
|
{
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
return false;
|
}
|
return $this->handleEmailSearch($email);
|
}
|
|
/**
|
* Searches for existing user from sanitized email
|
* @param string $email
|
* @return string|false The found service's User ID or false on failure
|
*/
|
abstract public function handleEmailSearch(string $email):string|false;
|
|
public function createServiceUser(int $userID, array $fields):string|false
|
{
|
$checked = $this->validateUserFields($userID, $fields);
|
$response = $this->handleCreateUser($checked);
|
if ($response['success']) {
|
$meta = Meta::forUser($userID);
|
$meta->set("{$this->service_name}_item_id", $response['result']['id']);
|
}
|
return $response['success'] ? $response['customer']??false : false;
|
}
|
|
protected function validateUserFields(int $userID, array $fields):array|false
|
{
|
foreach ($fields as $f => $v) {
|
$v = match ($f) {
|
'email' => filter_var($v, FILTER_SANITIZE_EMAIL),
|
'phone' => Sanitizer::sanitizePhone($v),
|
default => sanitize_text_field($v),
|
};
|
if (empty($v) && in_array($f, $this->requiredUserFields())) {
|
return false;
|
}
|
$fields[$f] = $v;
|
}
|
return $fields;
|
}
|
protected function requiredUserFields():array
|
{
|
return [];
|
}
|
|
/**
|
* @param array $data User Data
|
* @return array The created user ID or false on failure
|
*/
|
abstract protected function handleCreateUser(array $data):array;
|
|
/**
|
* @param int $userID
|
* @return void
|
*/
|
protected function handleTheSaveUser(int $userID):void
|
{
|
error_log('==== ['.$this->title.']::handleTheSaveUser ====');
|
//TODO: This is likely only for stuff like customers.
|
//If we have multiple stores connected, we may have to queue operations to update every connection's customer account if they made an order with that store
|
$role = jvbUserRole($userID);
|
$registrar = Registrar::getInstance($role);
|
if (!$registrar || !$registrar->hasIntegration($this->service_name)) {
|
return;
|
}
|
|
$this->queueOperation(self::$syncTo, [
|
'users' => [$userID],
|
]);
|
}
|
|
public function addDeleteUser():void
|
{
|
if (!has_action('delete_user', [$this, 'handleDeleteTerm'])) {
|
add_action('delete_user', [$this, 'handleDeleteTerm']);
|
}
|
}
|
public function removeDeleteUser():void
|
{
|
remove_action('delete_user', [$this, 'handleDeleteTerm']);
|
}
|
public function handleDeleteUser(int $userID):void
|
{
|
if (!$this->canSync['delete']) {
|
return;
|
}
|
|
$fields = $this->getSyncFields($userID, 'user');
|
if (empty($fields["_{$this->service_name}_item_id"])) {
|
return;
|
}
|
JVB()->queue()->add(
|
self::$deleteFrom,
|
0,
|
[
|
'fields' => [$userID => $fields],
|
'service' => $this->service_name,
|
'type' => 'user'
|
]
|
);
|
}
|
}
|