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' ] ); } }